API 레퍼런스

API 레퍼런스

프로그래밍 방식으로 AI 동영상을 생성합니다. 프롬프트를 제출하고, 완료를 폴링하고, 직접 동영상 URL을 받으세요 — 모두 REST로.

기본 URL: https://www.yoh.app

로그인하기 전까지 코드 예시에는 플레이스홀더 sk_live_YOUR_API_KEY가 사용됩니다.

빠른 시작

세 번의 API 호출로 첫 번째 동영상 만들기: 생성 → 폴링 → 다운로드.

Sign in and create a key at API Keys. Choose stories:generate and stories:read for generation and polling, then copy the full key immediately — it is shown only once. Keep it in a server environment variable or secret manager. Your account needs enough credits; creating a key does not add credits.

Check your key and balance (no charge)
curl https://www.yoh.app/api/v1/credits \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"

Submitting a generation request authorizes its credit charge; API integrations do not need an interactive price confirmation. To check cost first, POST the same body to /api/v1/stories/estimate. Optionally send X-Max-Generation-Credits with your maximum whole-number credits: exceeding it returns HTTP 409 before charging. Use the canonical host shown here; redirects from yoh.app can drop your Authorization header.

1. 동영상 생성
curl -X POST https://www.yoh.app/api/v1/stories/generate \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: YOUR_UNIQUE_REQUEST_ID" \
  -d '{ "prompt": "A dragon teaches a young knight how to fly", "contentType": "cinematic_story", "aspectRatio": "16:9", "scope": "full", "renderVideo": true }'

Replace YOUR_UNIQUE_REQUEST_ID with a unique ID for this operation. Reuse that ID and the identical body when retrying a queued generation request after a network failure. A new ID starts a new operation. Direct image/music requests may return a conflict on replay; inspect the asset before retrying. Poll the returned job every 20 seconds until completed or failed. A failed job is terminal; an error on a queued job can describe an automatic retry. For a text-only draft, use scope: "story" and omit renderVideo.

응답 — 작업이 큐에 추가됨
{
  "jobId": "cmnk8wu2q0001q3vpmqv93nmy",
  "status": "queued",
  "pollUrl": "/api/v1/stories/jobs/cmnk8wu2q0001q3vpmqv93nmy"
}
2. 완료될 때까지 폴링
curl https://www.yoh.app/api/v1/stories/jobs/cmnk8wu2q0001q3vpmqv93nmy \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
응답 — 작업 완료
{
  "status": "completed", "progress": 100,
  "storyId": "cmnk8xxnx000004lbd7siief7",
  "videoUrl": "https://cdn.example.com/video/clip.mp4",
  "error": null
}

완성! videoUrl은 즉시 다운로드, 스트리밍 또는 공유할 수 있는 공개 접근 URL입니다.

인증

모든 API 요청은 Bearer 스킴을 사용하여 Authorization 헤더에 API 키를 포함해야 합니다.

Authorization: Bearer sk_live_YOUR_API_KEY

API 키는 sk_live_로 시작합니다. API Keys page에서 키를 생성하고 관리할 수 있습니다. /api-keys

API 키를 비밀로 유지하세요. 클라이언트 측 코드나 공개 저장소에 노출하지 마세요. 키가 유출된 경우 즉시 API 키 페이지에서 취소하세요.

범위 및 크레딧

각 API 키에는 접근할 수 있는 엔드포인트를 제어하는 하나 이상의 범위가 부여됩니다. 스토리 텍스트 생성은 작업당 55 크레딧입니다. 클립 동영상이나 최종 렌더를 포함하는 범위(기본값인 full 포함)는 해당 비용을 미리 추가로 차감합니다. 모든 재시도 후 작업이 실패하면 크레딧이 환불됩니다.

범위접근 권한 부여
stories:generatePOST /api/v1/stories/generate, POST /stories/:id/assets, POST /stories/:id/storyboard, POST /stories/:id/videos, POST /stories/:id/render, POST /stories/:id/clips/:clipId/image, POST /stories/:id/clips/:clipId/video, POST /stories/:id/clips/:clipId/video-edit, POST /stories/:id/characters/:assetId/sheet, POST /stories/:id/soundtrack, POST /stories/:id/assets/:assetId/image, POST /stories/:id/clips/:clipId/regenerate, POST /stories/:id/continue, POST /stories/:id/clips-all, POST /stories/:id/clips/:clipId/grid-reference — the clip edits: POST/PATCH /stories/:id/clips, PATCH/DELETE /stories/:id/clips/:clipId — and the story-asset edits: PATCH /stories/:id/characters|scenes|items/:assetId. Also implies assets:write and series:write, so keys issued before those scopes existed keep working.
stories:readGET /api/v1/stories, GET /api/v1/stories/:id, GET /api/v1/stories/jobs/:id, GET /api/v1/credits, POST /api/v1/stories/estimate, POST /api/v1/stories/analyze-prompt. Implies series:read and assets:read.
stories:writeThe MCP server's editing tools (POST /api/mcp): clip content and dialogue, timeline moves/trims/captions, freeform overlays, cast and set edits. Spends no credits, so a key with this scope but WITHOUT stories:generate can edit a story and physically cannot pay to generate anything. Implied by stories:generate; implies assets:write.
assets:readGET .../characters, .../scenes, .../items, .../clips, GET /api/v1/series/:id/assets
assets:writePOST /api/v1/series/:id/assets, PATCH/DELETE /api/v1/series/:id/assets/:assetId, POST /api/v1/uploads
series:readGET /api/v1/series, GET /api/v1/series/:id
series:writePOST /api/v1/series, PATCH /api/v1/series/:id
social:publishPOST /api/v1/social/publish, GET /api/v1/social/publish/:postId
accounts:manageGET/POST /api/v1/accounts, GET/POST /api/v1/accounts/:accountId/credits. For platforms that serve many customers: provision an account per customer and fund it from your own balance. Implied by nothing — an existing key never gains it.

동영상 생성

동영상 생성 작업 큐 추가

POST/api/v1/stories/generatescope: stories:generate

프롬프트를 제출하고 즉시 jobId를 반환합니다. 동영상은 비동기로 생성됩니다. GET /api/v1/stories/jobs/:jobId을 폴링하여 진행 상황을 추적하세요.

요청 본문

필드타입상태설명
promptstring필수스토리 설명, 최대 5000자.
aspectRatiostring선택16:9, 9:16, 1:1, 21:9 중 하나. 기본값: 16:9.
renderVideoboolean선택false — 첫 번째 AI 클립 반환 (빠름, ~10분). true — Remotion을 통해 모든 클립을 하나의 합성 MP4로 렌더링 (~12–15분).
scopestring선택story (text only) or full (text, images and clip videos; default). Use renderVideo: true with full to include the final MP4 and its render fee. Other values return 400 before charging. For assets, storyboard, videos or render on an existing story, use the separate /api/v1/stories/:storyId endpoints below.
titlestring선택스토리의 표시 이름.
contentTypestring선택스토리 형식 카테고리. cinematic_story, news_analysis, persona_channel, ad_spot 중 하나. 기본값: cinematic_story.
localestring선택언어 코드 (예: en, zh-TW). 기본값: en.
seriesIdstring선택일관된 캐릭터 비주얼을 위해 기존 시리즈에 연결합니다.
assetsarray선택The cast and props for this story — reference assets already in your series library, or attach one-off assets inline. See the table below.
continuityModestring선택With a seriesId: how much of the previous episodes this one carries. episodic_continuous (default) picks up open threads, finale resolves them, one_off_episode keeps series plot out entirely.
isFinaleboolean선택Implied by continuityMode: "finale"; you rarely need both.
allowMultiStoryboolean선택Let one request split into several episodes when the prompt clearly carries more than one. Default false — N episodes means N charges, so it is opt-in.
seriesContinuityContextobject선택Escape hatch. Supplying it replaces the continuity we assemble from your series (open threads plus the last three episode recaps), so an incomplete object produces a weaker episode than sending nothing. Prefer continuityMode.

assets[] 선택

This array ATTACHES assets to one generation — it is not where an asset is defined. It carries only what is read inline; the full asset shape (character type and role, voice, scene lighting and mood, item material and condition) lives in the series library, which accepts all of it and which the pipeline reads directly. Reference the library by id rather than restating an asset here. Each entry either references an asset in your series library by seriesAssetId (upload it once via POST /api/v1/series/:seriesId/assets and reuse it forever), or defines a one-off asset inline with type + name. The two forms mix freely in one array. Whatever you attach is described to the writer, and its image is reused instead of a new one being generated — so a character keeps their face and a product keeps its packaging.

필드타입설명
seriesAssetIdstringReference an existing library asset. Everything else is optional when this is set.
typestringcharacter · scene · item · reference. Required for an inline asset.
namestringRequired for an inline asset. Also how the writer refers to it — name it in your prompt for it to appear.
descriptionstringOverrides the library description for this story only.
imageUrlstringImage for an inline asset. Re-hosted on our storage.
itemTypestringItems only: prop · product · logo. A logo is composited flat, never given 3D depth.
isHeroProductbooleanad_spot: the advertised product — appears in every clip. Fills heroItemId for you.
isPrimaryBackgroundbooleannews_analysis / persona_channel: the set reused across every shot. Fills primaryBackgroundAssetId for you.
useAsReferenceFramebooleanUse this image as the first frame / reference for image-to-video on clips featuring the asset.
genderstringCharacters only: surfaced to the writer.
agestringCharacters only: surfaced to the writer.
personalityTraitsstring[]Characters only: surfaced to the writer.
timeOfDaystringScenes only, e.g. dusk.
weatherstringScenes only, e.g. rain.
persistbooleanAccrue an inline asset into the series library so later stories can reference it by id. Default false.

Characters, scenes and items only enter the story when the prompt names them — attaching a whole series library does not drag its entire cast into every episode. The two pin flags are the exception: a pinned hero product or primary background is guaranteed regardless of whether the script says its name.

globalConfig 선택

Optional nested object controlling duration, output format, and model selection. Every field has a default; pass only what you want to override. The model fields (videoModel, imageModel, characterImageModel, modelStrategy) may also be passed at the top level as a convenience. Unknown model ids degrade to auto rather than failing the request.

This is a superset of the studio's own controls, not a mirror of them. resolution, qualityTier, modelStrategy and seed are API-only — the studio expresses the same choices through its quality picker. Two fields are advisory: they are shown to the writer as context but nothing enforces them, and they are marked below.

필드타입ValuesDefault
storyInputModestringauto · develop · follow_script (auto decides whether the prompt is an idea to develop or a finished script to follow verbatim)auto
durationSecnumber15–300 (total seconds)60
aspectRatiostring16:9 · 9:16 · 1:1 · 21:916:9
resolutionstring480p · 720p · 1080p720p
pacestringslow · normal · fastnormal
structurestringauto · 3_act · 4_act · montageauto
qualityTierstringdraft · balanced · premium (premium selects the advanced model lane)balanced
stylePresetstringpreset slug, or autoauto
platformTargetstringADVISORY — shown to the writer as context; nothing enforces it. youtube · tiktok · instagram · xyoutube
safetyModestringADVISORY — shown to the writer as context; not a content filter. standard · strictstandard
seedstringany string — shared across clips for continuity (API-only)—
videoModelstringa model id, or autoauto
videoEditModelstringvideo editing only — muapi/sd25-video-edit (Seedance 2.5 Video Edit), or auto. Omit to keep the existing editing recipe.auto
imageModelstringstoryboard frames + scene/item images — a model id, or auto (GPT Image 2)auto
characterImageModelstringcharacter portraits, sheets and appearance variants — a model id, or auto (Nano Banana 2)auto
modelStrategystringauto · fixed · cost_optimized · quality_first · hero_premiumauto
generationModestringDEPRECATED — ignored. The selected model determines frame or reference inputs.auto
localestringen · zh-TW · ja · ko · th · id (also accepted top-level)en

contentTypeConfig 선택

Optional per-contentType creative controls. Fields are validated against the selected content type; omitted fields fall back to sensible defaults. Pass the object matching your contentType. The two pin ids below are usually easier to set as isHeroProduct / isPrimaryBackground on an assets[] entry — an id you write here wins if you send both.

contentTypeFields
cinematic_story(no per-type fields — format craft is built in)
news_analysisprimaryBackgroundAssetId, primaryBackgroundName (pin a studio scene across anchor shots)
persona_channelprimaryBackgroundAssetId, primaryBackgroundName (pin the persona's set)
ad_spotheroItemId, heroProductName, heroTagline, heroKeyBenefit (the advertised product)

Other optional fields

필드타입설명
storyQualityConfigobject{ enableStyleAdaptedCharacters?: boolean, directClipImageGeneration?: boolean } — quality-rollout toggles. Omit to use pipeline defaults.
styleobjectStyle-consistency overrides: cinematicStyle, era, colorPalette, lightingStyle, filmGrain, lensCharacter, materialEmphasis, styleCategory, negativeHints, stylePreset.
seriesContinuityContextobjectSeries continuity context for episodic generation. Used together with seriesId.

Fields that aren't part of the selected contentType's schema are silently ignored — cinematic_story takes no per-type fields at all, so pass contentTypeConfig only for ad_spot, news_analysis, or persona_channel.

Full control example
curl -X POST https://www.yoh.app/api/v1/stories/generate \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "A 30s spot for a titanium water bottle that keeps ice for 24h",
    "contentType": "ad_spot",
    "scope": "full",
    "renderVideo": true,
    "globalConfig": {
      "durationSec": 30,
      "resolution": "1080p",
      "modelStrategy": "hero_premium"
    },
    "contentTypeConfig": {
      "heroProductName": "Northpeak Flask",
      "heroTagline": "Ice, 24 hours later.",
      "heroKeyBenefit": "Vacuum-sealed titanium keeps ice frozen for a full day"
    }
  }'

응답 202 Accepted

{ "jobId": "cmnk8wu2q0001q3vpmqv93nmy", "status": "queued", "pollUrl": "/api/v1/stories/jobs/..." }

Step-by-Step Endpoints

After generating a story with scope: "story", use these endpoints to generate storyboard images, clip videos, and the final rendered MP4 as separate steps.

POST/api/v1/stories/:storyId/assets

Generate the cast and world: character portraits + 2×2 character sheets, plus scene and item images. Run this before /storyboard for the best face consistency. Skips assets that already have images.

Credits: charged per generated image | Scope: stories:generate
POST/api/v1/stories/:storyId/storyboard

Generate storyboard grid images for all clips. Skips clips that already have images.

Credits: 20 minimum, then ~3 per clip beyond that | Scope: stories:generate
POST/api/v1/stories/:storyId/videos

Generate video for each clip. Skips clips that already have a video URL.

Credits: ~96 per 8s clip (default model; scales with duration) | Scope: stories:generate
POST/api/v1/stories/:storyId/render

Stitch all clip videos + music + captions into a final MP4.

Credits: 10 | Scope: stories:generate

One Clip at a Time

The batch endpoints above only fill in what's missing — /storyboard skips clips that already have an image and /videos skips clips that already have a video. To re-roll a shot you don't like, target it directly. These are the same operations as the editor's per-clip regenerate buttons, and they overwrite whatever that clip already has.

POST/api/v1/stories/:storyId/clips/:clipId/image

(Re)generate the storyboard image for ONE clip. Optional body: prompt (art direction for this shot) and imageModel.

Credits: one image (no pipeline base) | Scope: stories:generate
POST/api/v1/stories/:storyId/clips/:clipId/video

(Re)generate the video for ONE clip. Optional body: videoModel. There is no mode to pick. The selected model locks the input mode: frame models use the current clip image as start and the next clip image as end; reference models receive their supported, named image, video and voice references. Missing required clip images are rejected before charging. The final clip uses start-only where supported.

Credits: that clip's duration on the story's lane (returned as estimatedCredits) | Scope: stories:generate
POST/api/v1/stories/:storyId/clips/:clipId/regenerate

Rebuild ONE clip end to end — a fresh storyboard image, then a fresh video from it — as a single job. The right call after changing a clip's description or cast: regenerating only the video would run it against the old image. Optional body: prompt (art direction for the new image).

Credits: image + video, returned as breakdown | Scope: stories:generate
POST/api/v1/stories/:storyId/clips/:clipId/grid-reference

Clip authoring supports description for scene and image context, and optional motionDescription for video movement and timed beats. Video generation uses both. When motion is empty, video generation automatically creates and saves timed motion first. Send null to prepare new motion on the next video run. Draw a multi-panel continuity reference sheet for one clip. The panels are derived from the clip's time beats — an authored beat track when motionDescription carries one, otherwise the beat plan its generation length implies — so the sheet mirrors that shot rather than a fixed set of generic moments. Two panels per beat, snapped to a supported grid layout. Runs inline and returns the grid URL.

Credits: one storyboard grid | Scope: stories:generate
POST/api/v1/stories/:storyId/clips-all

Draw every missing storyboard image and then generate every missing clip video, as ONE job. Running /storyboard and /videos separately lets the video pass start while images are still missing. Both phases only fill gaps, so re-running after a partial failure is safe.

Credits: images + videos, returned as breakdown | Scope: stories:generate
POST/api/v1/stories/:storyId/continue

Write a NEW storyline branching off an existing clip — an alternative continuation the story keeps alongside the original, which is untouched. Required body: branchFromClipId. Optional prompt (where the story goes) and clipCount. TEXT only — the new clips arrive with no images or video, so drawing them is a separate, separately-priced step.

Credits: one story continuation | Scope: stories:generate
GET/api/v1/stories/:storyId/renders

The story's finished videos, newest first, with latestSuccessful called out so a failed render is never mistaken for the current cut. A story keeps every render. Optional ?limit (max 50).

Free | Scope: stories:read
POST/api/v1/stories/:storyId/clips/:clipId/video-edit

Rewrite a clip's existing video from an instruction — video-to-video, so subject, composition and motion carry over. Required body: prompt ("make it night and rainy", "push the camera in slowly"). Optional durationSeconds (4–15); omit it and the edit follows the source's own length. The clip must already have a video, and the result becomes a new selectable version rather than replacing the old one.

Credits: one video edit (flat, independent of duration) | Scope: stories:generate
Re-roll one shot
# Regenerate clip 3's image, then its video — the rest of the story is untouched
curl -X POST https://www.yoh.app/api/v1/stories/STORY_ID/clips/CLIP_ID/image \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
# Poll jobId until completed

curl -X POST https://www.yoh.app/api/v1/stories/STORY_ID/clips/CLIP_ID/video \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"videoModel":"bytedance/seedance-2.0"}'
# Poll jobId until completed → the clip's videoUrl is replaced

Editing the Story

Direct the story instead of only re-rolling it: rewrite a shot, retime it, add a beat, reorder, or cut one. These are immediate (no job to poll) and cost no credits. A clip's description provides scene and image context; optional motionDescription provides video choreography. Video uses both — edit the appropriate field, then call that clip's /image or /video to re-shoot it with the new direction.

PATCH/api/v1/stories/:storyId/clips/:clipId

Edit a clip. Send only what you want to change: title, description, motionDescription, duration, generationDuration, resolution, transitionStyle, sceneId, characterIds, itemIds, dialogue, audioFx.

Media columns (imageUrl, videoUrl, generation history) are deliberately not writable — those belong to the pipelines. Use the clip's /image and /video endpoints to change them.

Credits: 0 | Scope: stories:generate
POST/api/v1/stories/:storyId/clips

Insert a clip. title and description required; place it with afterClipId or beforeClipId (omit both to append). Following clips shift down automatically. The new clip has no image or video yet.

Credits: 0 | Scope: stories:generate
PATCH/api/v1/stories/:storyId/clips

Reorder. Body: { "clips": [{ "id": "...", "order": 0 }, …] } — send every clip with a contiguous 0-based order.

Credits: 0 | Scope: stories:generate
DELETE/api/v1/stories/:storyId/clips/:clipId

Cut a clip; the remaining orders close up. A story must keep at least one clip.

Credits: 0 | Scope: stories:generate
Rewrite a shot, then re-shoot it
# 1. Rewrite the direction + retime the shot (immediate, free)
curl -X PATCH https://www.yoh.app/api/v1/stories/STORY_ID/clips/CLIP_ID \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "description": "The keeper climbs the stairs as the lamp flickers behind him.",
    "duration": 7
  }'

# 2. Re-shoot it with the new direction
curl -X POST https://www.yoh.app/api/v1/stories/STORY_ID/clips/CLIP_ID/image \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
curl -X POST https://www.yoh.app/api/v1/stories/STORY_ID/clips/CLIP_ID/video \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Step-by-step example
# Step 1: Generate story text (~1-2 min)
curl -X POST https://www.yoh.app/api/v1/stories/generate \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prompt":"A samurai cat guards a cherry blossom tree","contentType":"cinematic_story","scope":"story"}'
# Poll jobId until completed → get storyId

# Step 2: Generate storyboard images (~3-5 min)
curl -X POST https://www.yoh.app/api/v1/stories/STORY_ID/storyboard \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
# Poll until completed

# Step 3: Generate clip videos (~1-3 min per clip)
curl -X POST https://www.yoh.app/api/v1/stories/STORY_ID/videos \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
# Poll until completed

# Step 4: Render final MP4 (~1-2 min)
curl -X POST https://www.yoh.app/api/v1/stories/STORY_ID/render \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
# Poll until completed → videoUrl

작업 폴링

작업 상태 가져오기

GET/api/v1/stories/jobs/:jobIdscope: stories:read

POST /api/v1/stories/generate 호출 후 이 엔드포인트를 폴링합니다. 스토리 길이와 renderVideo 활성화 여부에 따라 작업은 보통 8–15분이 소요됩니다. 15–30초마다 폴링하는 것을 권장합니다.

작업 상태 생명주기

queuedgenerating_storygenerating_assetsgenerating_videosrendering_videocompleted또는failed

Which statuses you see depends on the scope you requested — a story job goes straight from generating_story to completed. A transient failure sends the job back to queued with error set to a retry message; that is not terminal, so treat only completed and failed as final states.

응답 필드

필드타입설명
jobIdstring고유 작업 식별자.
statusstring현재 상태. 위의 생명주기 참조.
progressnumber완료 백분율을 나타내는 0–100 정수.
storyIdstring|null스토리 생성 완료 후 설정. 에셋 가져오기에 사용.
renderVideoboolean합성 최종 MP4가 요청되었는지 여부.
videoUrlstring|null상태가 completed일 때의 절대 동영상 URL. 합성 MP4(renderVideo: true) 또는 첫 번째 클립의 AI 동영상(renderVideo: false).
errorstring|null사람이 읽을 수 있는 오류 메시지. 실패 시 설정되며, 재시도 중(상태가 queued로 돌아감)에도 설정됩니다. 성공 시 항상 null.
createdAt / startedAt / completedAtstring (ISO)타임스탬프.

스토리

스토리 목록

GET/api/v1/storiesscope: stories:read

스토리의 페이지 목록을 반환합니다.

쿼리 파라미터

파라미터기본값설명
page1페이지 번호 (≥ 1).
limit20페이지당 결과 수 (1–100). Capped at 100.
status—스토리 상태로 필터링.

This is the only endpoint that returns a pagination envelope alongside data:

{
  "data": [ { "id": "cmnk9cibl0004q3vpijkqfwl4", "title": "...", "status": "completed" } ],
  "pagination": { "page": 1, "limit": 20, "total": 137 }
}

스토리 가져오기

GET/api/v1/stories/:storyIdscope: stories:read

캐릭터, 장면, 아이템, 클립의 수를 포함한 단일 스토리의 전체 메타데이터를 반환합니다.

{
  "id": "cmnk9cibl0004q3vpijkqfwl4",
  "title": "A robot chef cooks in a futuristic kitchen",
  "status": "completed", "aspectRatio": "9:16", "locale": "en",
  "counts": { "characters": 3, "scenes": 4, "items": 6, "clips": 6 },
  "createdAt": "2026-04-04T11:37:04.662Z"
}

에셋 개요

작업이 완료되면 작업이 반환한 storyId를 사용하여 스토리에 대해 생성된 모든 에셋을 가져올 수 있습니다. 모든 에셋 엔드포인트에는 assets:read 범위가 필요합니다.

GET /api/v1/stories/:id/characters

캐릭터 — 이름, 설명, 역할, imageUrl, 음성 정보

GET /api/v1/stories/:id/scenes

장면 — 설명, 시간대, 날씨, 조명, imageUrl

GET /api/v1/stories/:id/items

소품 / 아이템 — 설명, 중요도, imageUrl

GET /api/v1/stories/:id/clips

클립 — 내레이션, 시간, videoUrl, imageUrl, audioUrl

캐릭터

캐릭터 목록

GET/api/v1/stories/:storyId/charactersscope: assets:read
{
  "data": [{
    "id": "char_abc123", "name": "Chef Axon", "role": "protagonist",
    "description": "A robot chef with a warm personality",
    "imageUrl": "https://cdn.example.com/characters/chef-axon.jpg",
    "voiceName": "Neutral-EN", "order": 0
  }]
}

클립 및 동영상

클립 목록

GET/api/v1/stories/:storyId/clipsscope: assets:read

각 클립은 AI 생성 동영상, 정지 이미지, 오디오가 있는 장면 세그먼트입니다. 모든 URL 필드는 절대 경로이며 공개 접근 가능합니다.

{
  "data": [{
    "id": "clip_001", "order": 0, "narration": "In the year 2157...",
    "duration": 8,
    "videoUrl": "https://cdn.example.com/generated/video/clip1.mp4",
    "imageUrl": "https://cdn.example.com/generated/image/clip1.jpg",
    "audioUrl": null, "status": "completed"
  }]
}

renderVideo가 false인 경우, 작업의 videoUrl은 첫 번째 클립의 동영상을 가리킵니다. 이 엔드포인트를 사용하여 각 클립의 동영상 URL을 가져오세요.

시리즈 에셋 라이브러리

한 번 업로드하고 계속 재사용하세요

POST/api/v1/series/:seriesId/assetsscope: assets:write

스튜디오 에셋 패널이 기록하는 것과 동일한 라이브러리입니다. 캐릭터, 배경, 소품, 스타일 레퍼런스를 업로드한 뒤 반환된 id를 보관하면 생성 시 assets 배열의 seriesAssetId 로 해당 id를 넘겨 참조할 수 있습니다. 영상마다 이미지 링크를 새로 만들 필요가 없습니다. 전달한 이미지는 당사 스토리지에 다시 호스팅되므로 원래 업로드 URL이 만료돼도 id는 계속 동작합니다.

필드타입설명
typestringcharacter, scene, item 또는 reference.
namestring표시 이름이자 이후 생성에서 매칭에 사용되는 키입니다.
imageUrlstring이미지의 공개 URL. 대신 imageBase64 를 보낼 수도 있습니다.
itemTypestring소품 전용: prop, product, logo. logo 는 평면으로 합성되며 3D 입체감이 적용되지 않습니다.
isHeroProductboolean소품 전용: 이 광고의 주인공 제품으로, 모든 클립에 등장합니다.
isPrimaryBackgroundboolean장면 전용: 모든 앵커 샷에서 재사용되는 스튜디오/세트.
  • 에셋을 하나만 보내거나 "assets" 배열로 최대 100개를 한 번에 등록할 수 있습니다.
  • 기본값은 이름이 정확히 일치할 때 병합하므로 같은 카탈로그를 다시 업로드해도 멱등합니다. ?mergeStrategy=fuzzy 는 이름 변경에 관대한 매칭, insert 는 항상 새로 생성합니다.
  • 소품에 isHeroProduct(ad_spot), 장면에 isPrimaryBackground(news_analysis / persona_channel)를 설정하면 고정됩니다. 생성 시 자동으로 해석되므로 heroItemId를 직접 쓸 필요가 없습니다.
범위접근 권한 부여
GET/api/v1/series
POST/api/v1/series
GET/api/v1/series/:seriesId
PATCH/api/v1/series/:seriesId
GET/api/v1/series/:seriesId/assets?type=character
POST/api/v1/series/:seriesId/assets
PATCH/api/v1/series/:seriesId/assets/:assetId
DELETE/api/v1/series/:seriesId/assets/:assetId
POST/api/v1/uploads
PATCH/api/v1/stories/:storyId/characters/:assetId
PATCH/api/v1/stories/:storyId/scenes/:assetId
PATCH/api/v1/stories/:storyId/items/:assetId
POST/api/v1/stories/estimate
GET/api/v1/credits

End to end — put a brand's logo in an ad, and keep the id for every ad after it:

1 — create the show (once)
curl -X POST https://www.yoh.app/api/v1/series \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Acme brand films","styleGuide":{"cinematicStyle":"clean product cinematography"}}'
# → { "series": { "id": "series_abc", ... } }
2 — upload the logo (once) and keep the id
curl -X POST https://www.yoh.app/api/v1/series/series_abc/assets \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "type": "item",
        "name": "Acme Logo",
        "itemType": "logo",
        "description": "Wordmark, orange on white",
        "imageUrl": "https://your-admin.example.com/tmp/logo.png"
      }'
# → { "asset": { "id": "series-item-...", "imageUrl": "https://...blob..." } }
# That imageUrl is ours now — your temporary link can expire.
3 — generate an ad that features it (every time)
curl -X POST https://www.yoh.app/api/v1/stories/generate \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "prompt": "A 30-second spot for the Acme cordless drill",
        "contentType": "ad_spot",
        "seriesId": "series_abc",
        "assets": [
          { "seriesAssetId": "series-item-...", "isHeroProduct": true }
        ]
      }'
# → { "jobId": "...", "pollUrl": "/api/v1/stories/jobs/..." }

Step 2 happens once per asset, not once per video. Step 3 repeats forever with the same id — and because the asset is pinned, the logo is guaranteed in every clip even if the script never says its name. To check the cost before committing, POST the same body to /api/v1/stories/estimate.

시리즈 에셋

시리즈에 캐릭터 이미지 등록

POST/api/v1/series-assets/ensure-character-imagescope: assets:write

POST /api/v1/series-assets/ensure-character-image 는 그대로 동작하지만 캐릭터만 지원합니다. 이 엔드포인트를 권장합니다.

시리즈에 대한 캐릭터 참조 이미지를 업로드하고 저장하여 AI가 해당 시리즈의 모든 스토리에서 일관된 외모를 사용할 수 있도록 합니다. 이 작업은 idempotent입니다 — 동일한 캐릭터 이름으로 여러 번 호출해도 안전합니다.

필드타입설명
seriesIdstring캐릭터를 연결할 시리즈의 ID.
characterNamestring캐릭터 이름 (시리즈 내 고유 키로 사용).
imageUrlstring캐릭터 참조 이미지의 공개 접근 가능한 URL.

소셜에 게시

완성된 영상을 연결된 계정으로 곧바로 보냅니다. 자격 증명 하나로 생성 → 렌더링 → 게시까지 자동화할 수 있죠. 앱에서 계정을 먼저 연결하세요. 이 엔드포인트는 그 계정으로 게시합니다.

영상 게시하기

POST/api/v1/social/publishscope: social:publish

202와 { postId, status: "publishing" }을 반환하고 업로드는 백그라운드에서 진행됩니다 — 아래 상태 엔드포인트를 폴링하세요. 대신 업로드가 끝날 때까지 기다리게 하려면 wait: true를 넘기세요(몇 분 걸릴 수 있습니다). 동영상 URL은 이 스토리의 성공한 렌더링 결과 또는 원본 업로드 동영상과 일치해야 합니다. YouTube 스트림은 공개 주소만 허용하며 기존 512 MB 업로드 제한이 적용됩니다.

필드타입상태설명
socialAccountIdstring필수이 키의 사용자가 소유한 연결된 계정.
storyIdstring필수이 키의 사용자가 소유한 스토리.
videoUrlstring필수공개 MP4 URL — 완료된 작업의 videoUrl.
platformstring필수facebook · instagram · tiktok · threads · youtube
captionstring선택게시물 문구.
youtubeobject선택{ title, privacyStatus, categoryId, tags } — YouTube 전용.
durationSecnumber선택게시 전 플랫폼 검증에 사용됩니다.
aspectRatiostring선택게시 전 플랫폼 검증에 사용됩니다.
fileSizeBytesnumber선택게시 전 플랫폼 검증에 사용됩니다.
waitboolean선택업로드가 끝날 때까지 기다립니다. 기본값 false.
curl -X POST https://www.yoh.app/api/v1/social/publish \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "socialAccountId": "acct_123",
    "storyId": "STORY_ID",
    "videoUrl": "https://cdn.example.com/video/final.mp4",
    "platform": "youtube",
    "caption": "Episode 1 is live",
    "youtube": { "title": "The Lighthouse — Ep 1", "privacyStatus": "public" }
  }'

게시 상태 확인

GET/api/v1/social/publish/:postIdscope: social:publish

수명 주기: publishing → published | failed.

관리형 계정

고객이 많은 플랫폼을 위한 기능입니다. 모두가 키 하나와 크레딧 풀 하나를 함께 쓰면 — 사용량 많은 고객 한 명이 잔액을 다 써버리고, 누가 얼마를 썼는지도 알 수 없죠 — 대신 고객마다 계정을 발급하고 각각을 당신의 잔액에서 충전하세요. 고객은 여기에 가입하지도, 저희에게 결제하지도 않습니다. 상거래 관계는 그대로 당신의 것입니다.

계정 발급하기

POST/api/v1/accountsscope: accounts:manage

externalId는 고객에 대한 당신 자신의 식별자입니다. 같은 값으로 두 번 호출하면 새로 만들지 않고 기존 계정을 돌려주므로 재시도해도 안전합니다. API 키는최초 생성 시 단 한 번 돌아옵니다 — 저희는 해시만 저장하며 다시 보여줄 수 없으니 받는 즉시 보관하세요. 새 계정은 잔액 0으로 시작합니다.

필드타입설명
externalIdstring필수. 이 고객에 대한 당신의 식별자(예: 테넌트 ID). 플랫폼 내에서 고유해야 합니다.
namestring선택 라벨. 당신의 리포팅용입니다.

관리 계정 키 교체

POST/api/v1/accounts/:accountId/api-keysscope: accounts:manage

최초 키를 분실했다면 소유 플랫폼의 키로 요청 본문 없이 이 엔드포인트를 호출하세요. 계정 ID 또는 externalId를 사용할 수 있습니다. 하나의 트랜잭션으로 기존 키를 모두 폐기하고 새 apiKey를 한 번만 반환합니다(201). 안전하게 보관하세요. 이 호출을 반복하면 키가 다시 교체됩니다. 일반 계정 생성 요청을 재시도해도 기존 키는 교체되지 않습니다.

계정 충전하기

POST/api/v1/accounts/:accountId/creditsscope: accounts:manage

크레딧을 당신의 잔액에서 그 계정으로 옮깁니다 — 지급이 아니라 이체이므로 실제로 구매한 만큼만 나눠줄 수 있습니다. 당신의 잔액이 부족하면 402를 반환합니다. :accountId에는 저희 계정 ID나 당신의 externalId 중 아무거나 넣을 수 있습니다.

필드타입설명
amountinteger필수. 옮길 크레딧 수. 양수여야 합니다.
idempotencyKeystring청구서나 구독 기간 ID를 넘기세요. 재시도되거나 중복 전달된 호출은 두 번 충전하는 대신 원래 이체를 돌려줍니다. 같은 키, 수신 계정, 금액으로 재시도하세요. 플랫폼 잔액이 소진되어도 재시도 시 크레딧이 다시 이체되지 않습니다. 수신 계정이나 금액이 바뀌면 409를 반환합니다. 너무 긴 키는 잘라내지 않고 거부합니다.
descriptionstring선택 메모. 크레딧 내역에서 이체 양쪽 모두에 표시됩니다.

GET /api/v1/accounts는 각 계정의 잔액과 누적 사용액을 함께 나열하고, GET /api/v1/accounts/:accountId/credits는 하나를 읽습니다. 관리형 계정 자신의 키는 생성 범위만 가지므로 계정을 발급하거나 충전할 수 없습니다.

Claude Code(MCP)

Yoh는 MCP 서버를 제공하여 AI 코딩 에이전트(Claude Code, Claude Desktop 또는 모든 MCP 클라이언트)가 스토리를 직접 열고 편집할 수 있게 합니다. 클립 내용과 대사, 타임라인 이동과 트림, 프리폼 오버레이, 그리고 매번 명시적으로 승인한 경우에만 재생성과 렌더링을 수행합니다. 브라우저 편집기와 동일한 규칙을 따르므로 터미널에서 한 편집도 드래그했을 때와 정확히 같은 위치에 놓입니다.

연결

/api-keys에서 API 키를 만들고(크레딧을 쓰지 않고 편집만 하려면 stories:read와 stories:write 범위면 충분합니다) 다음을 실행하세요:

claude mcp add --transport http yoh https://www.yoh.app/api/mcp \
  --header "Authorization: Bearer sk_live_YOUR_API_KEY"

또는 프로젝트 설정을 커밋하면 팀 전체가 저장소를 열 때 연결을 갖게 됩니다. 키는 각자 환경 변수로 제공합니다:

.mcp.json
{
  "mcpServers": {
    "yoh": {
      "type": "http",
      "url": "https://www.yoh.app/api/mcp",
      "headers": { "Authorization": "Bearer ${YOH_API_KEY}" },
      "timeout": 900000
    }
  }
}

그다음에는 Claude Code에 말만 하면 됩니다: "3화의 호흡을 조여줘 — 빈 구간을 잘라내고, 자막을 비트에 맞추고, 다시 렌더링해줘". Claude Code 안에서 /mcp를 실행하면 연결을 확인할 수 있습니다.

에이전트가 할 수 있는 일

도구범위예시
읽기stories:readlist_stories · get_story · get_timeline · get_clip · get_job · get_credits · get_asset_images · get_soundtrack · list_overlays · get_revoice · list_style_presets · list_voices · list_renders · list_audio_assetsget_clip은 스토리보드 이미지를 함께 주고, withFrames를 켜면 생성된 영상에서 뽑은 실제 프레임까지 줍니다. 에이전트가 클립의 실제 모습을 볼 수 있는 유일한 방법입니다. get_asset_images는 배역·세트·소품에 대해 같은 일을 합니다.
Template versionsstories:write / stories:read / assets:readcreate_template_version · get_template_version · list_template_destinations · list_template_assets · update_template_version · analyze_template_version · get_template_analysis · accept_template_adaptation · quote_template_version · commit_template_version · cancel_template_version · fork_template_versionMap existing or newly created series assets, review wording and language, then create a free draft. REST families: /api/v1/template-version-sessions/:sessionId and /api/v1/template-version-runs/:runId. Template REST OpenAPI schemas and endpoints
Template generation ★stories:generategenerate_template_versionQuote the run first, show the maximum credits, then confirm. Poll get_template_version for persisted progress and recovery.
생성 ★stories:generateestimate_story · create_story프롬프트에서 완전히 새로운 스토리를 씁니다. estimate_story는 무료이며 계획 전체를 미리 견적 내줍니다(개별 견적으로는 불가능합니다). 영상 비용을 즉시 청구하는 "full" 대신 scope="story"(텍스트만)로 시작하세요.
시리즈series:read / series:writelist_series · get_series · create_series · update_series · list_series_assets · add_series_asset · delete_series_asset하나의 스토리 위에 있는 층입니다. 에피소드를 가로질러 이어지는 공용 배역·세트·스타일이죠. create_story의 seriesId로 새 스토리를 붙이면 그대로 물려받습니다.
클립과 대사stories:writeupdate_clip · update_clips · set_all_clips · reorder_clips · add_clip · delete_clip · duplicate_clip · reorder_clip · set_clip_duration · select_clip_version · add_dialogue_line · update_dialogue_line · remove_dialogue_line · hide_dialogue_line · add_audio_fx · update_audio_fx · remove_audio_fx · update_story · update_caption_settings클립을 넘치는 대사는 클립 길이를 자동으로 늘립니다. 텍스트 편집이 이미 생성된 미디어를 바꾸는 일은 없습니다. update_clips는 여러 클립을 각각 다른 값으로 한 번에 고칩니다. set_all_clips는 하나의 값을 모든 클립에 적용하며 텍스트는 거부합니다 — 같은 설명을 전편에 쓰면 이야기가 납작해지기 때문이죠. 둘 다 항목 하나라도 잘못되면 배치 전체를 거부합니다. reorder_clips는 전체 순서를 한 번에 지정합니다.
타임라인stories:writemove_clip · resize_clip · trim_clip · reset_clip_timing · close_gaps · move_caption · hide_clip_video · pin_dialogue_read · export_clip_to_freeform · export_caption_to_freeform · remove_soundtrack클립을 옮기면 뒤의 모든 클립이 함께 밀립니다. 길이 조절은 시작점을 고정하므로 시작 쪽을 끌면 앞부분 트림이 됩니다. 스토리 항목은 삭제하거나 분할할 수 없습니다 — 숨기거나 프리폼 오버레이로 내보내세요.
오버레이stories:writeadd_overlay · update_overlay · move_overlay · resize_overlay · split_overlay · duplicate_overlay · delete_overlay · delete_freeform_row · upload_media같은 행의 오버레이는 겹칠 수 없습니다. 충돌하는 배치는 거절되며 비어 있는 행을 함께 알려줍니다. 미디어에는 호스팅된 https 소스가 필요합니다.
스타일stories:writelist_style_presets · apply_overlay_preset · set_caption_style편집기가 실제로 쓰는 텍스트 프리셋, 자막 템플릿, 약 32가지 등장·퇴장 애니메이션입니다. 에이전트가 CSS를 지어내는 대신 제품이 제공하는 것을 적용하게 하죠. 자막 스타일은 대사 한 줄 단위입니다.
배역과 세트stories:writeadd_character · update_character · delete_character · add_scene · update_scene · delete_scene · add_item · update_item · delete_item캐릭터를 지우면 모든 참조가 복구됩니다 — 클립 배역은 물론 그 대사도 이름이 같은 다른 캐릭터나 내레이터에게 넘어갑니다.
음성 캐스팅stories:writelist_voices · set_character_voice스토리 언어로 걸러진 기본 음성와 계정에서 준비된 복제 음성입니다. 배정은 무료이며, 이미 녹음된 대사는 생성 당시의 음성를 유지합니다.
재녹음stories:writeget_revoice · set_revoice_enabled · select_revoice_take · undo_revoice새 낭독과 원래 연기 사이를 오가는 것은 무료이고 되돌릴 수 있습니다. 두 테이크 모두 보관됩니다.
생성 ★stories:generateregenerate_clip · regenerate_clip_image · edit_clip_image · enhance_clip_image · generate_clip_video · edit_clip_video · revoice_clip · regenerate_character_sheet · regenerate_asset_image · generate_all_asset_images · generate_soundtrack · generate_storyboard · generate_all_videos · generate_all_clips · generate_clip_grid_reference · continue_story_from_clip · render_story각 쌍은 '수정'과 '다시 그리기'의 대비입니다. edit_clip_image는 지시에 따라 현재 프레임을 고쳐 쓰고, regenerate_clip_image는 새로 그립니다. edit_clip_video는 영상→영상 변환으로 피사체·구도·움직임을 지키고, generate_clip_video는 스토리보드 이미지에서 다시 만듭니다. enhance_clip_image는 배역 시트를 기준으로 얼굴 닮음새를 되돌립니다. regenerate_clip은 이미지→영상을 한 작업으로 처리하므로 클립 설명이나 배역을 바꾼 뒤에 알맞습니다. generate_clip_grid_reference는 고정된 일반적 순간이 아니라 클립 자신의 타임 비트에서 나온 칸으로 연속성 시트를 그립니다.

크레딧 사용은 이중으로 잠겨 있습니다 ★ 표시된 도구는 첫 호출에서 절대 크레딧을 쓰지 않습니다. 크레딧 견적과 서명된 confirmToken을 돌려주며, 에이전트는 견적을 보여준 뒤 사용자가 명시적으로 동의해야만 진행합니다. 또한 stories:generate가 없는 키는 구조적으로 크레딧을 쓸 수 없습니다. 읽고 편집만 하는 에이전트를 원한다면 stories:write만 가진 키를 발급하세요.

안전하고 정확하게 유지되는 방식

  • 서버가 편집기의 규칙(슬라이드 이동, 트림 경계, 자막 레인, 오버레이 행)을 강제하며, 위반 시 값을 조용히 잘라내지 않고 허용 범위를 알려줍니다.
  • 에이전트는 yoh://guide/timeline 리소스에서 전체 규칙을 읽고, /mcp__yoh__edit-story 프롬프트가 안전한 작업 흐름을 제공합니다.
  • 생성은 비동기입니다. 도구가 jobId를 반환하면 에이전트가 REST API와 동일하게 get_job으로 폴링합니다.
  • .mcp.json에 긴 timeout(예: 900000)을 설정해 렌더링이 클라이언트에서 시간 초과되지 않게 하세요.

연결 확인하기

저장소에 스모크 테스트가 포함되어 있습니다. 실제 프로토콜로 통신하고 규칙을 검증하며 사용 게이트가 작동함을 증명합니다. 크레딧은 전혀 쓰지 않고, 유일한 쓰기 작업도 되돌립니다:

node scripts/mcp-smoke.mjs --url https://www.yoh.app/api/mcp --key sk_live_YOUR_API_KEY
# read-only variant:
node scripts/mcp-smoke.mjs --url https://www.yoh.app/api/mcp --key sk_live_YOUR_API_KEY --skip-write

API 키 관리

API 키 관리 엔드포인트는 API 키가 아닌 session authentication(로그인한 사용자 쿠키)를 사용합니다. 브라우저 기반 워크플로에는 API Keys page를 사용하거나, 유효한 세션으로 백엔드에서 프로그래밍 방식으로 이 엔드포인트를 호출하세요. /api-keys

API 키 목록

GET/api/v1/api-keysscope: session
{
  "data": [{
    "id": "key_abc123", "name": "Production",
    "keyPrefix": "sk_live_7WH3",
    "scopes": ["stories:generate", "stories:read", "assets:read"],
    "revokedAt": null, "createdAt": "2026-04-03T10:00:00.000Z"
  }]
}

API 키 생성

POST/api/v1/api-keysscope: session
// Response — full key shown once
{
  "id": "key_abc123",
  "key": "sk_live_...",
  "name": "My integration",
  "keyPrefix": "sk_live_7WH3",
  "scopes": ["stories:generate", "stories:read", "assets:read"]
}

API 키 취소

DELETE/api/v1/api-keys/:idscope: session
{ "id": "key_abc123", "revoked": true }

오류

HTTP 409 with error.code = generation_budget_exceeded means your optional X-Max-Generation-Credits cap was exceeded. The response includes quote.credits; nothing was charged. Change the request or explicitly raise your budget to retry. A malformed cap returns 400. Other 409 conflicts can mean an idempotency key was reused with different input or work is already running. Template-version generation uses its own persisted quoteId contract; see its OpenAPI reference.

대부분의 엔드포인트는 기계가 읽을 수 있는 code가 포함된 error 객체를 반환합니다. 일부 전용 엔드포인트는 문자열 오류나 최상위 코드를 반환할 수 있으므로 항상 HTTP 상태를 확인하세요.

{ "error": { "code": "insufficient_credits", "message": "...", "status": 402 } }
HTTP코드의미
401unauthorizedAPI 키가 없거나 유효하지 않습니다.
403forbiddenAPI 키에 필요한 범위가 없습니다.
404not_found요청한 리소스가 존재하지 않습니다.
400bad_request잘못된 요청 본문 또는 파라미터.
402insufficient_credits이 작업을 수행할 크레딧이 부족합니다.
429rate_limited요청이 너무 많습니다. Retry-After 헤더를 확인하세요.
500internal_error예기치 않은 서버 오류.

속도 제한: API 키당 생성 엔드포인트는 분당 5회, 읽기와 크레딧을 쓰지 않는 클립 편집은 분당 60회 요청할 수 있습니다. 제한에 도달하면 응답에 기다릴 초 수를 포함하는 Retry-After 헤더가 포함됩니다. 실패한 작업(모든 재시도 후)은 차감된 크레딧을 자동으로 환불합니다.