초대하고 적립

초대 보상 안내

초대 링크를 공유하세요. 친구가 링크로 가입하고 충전하면 이후 충전마다 표시된 보상을 받을 수 있습니다.

Runway로 프레임 레이트를 높인 뒤 영상을 검수하는 방법

로컬 영상 준비와 업로드부터 enhance_frame_rate 제출, 작업 조회, 출력 저장, FPS·왜곡·오디오 싱크 검수까지 이어지는 전체 절차입니다.

목차
Runway로 프레임 레이트를 높인 뒤 영상을 검수하는 방법

완성된 로컬 영상이 있어도 Runway에서 프레임 레이트를 높인 출력은 아직 없을 수 있습니다. 검수 전에 파일을 준비해 업로드하고 targetFramerate를 제출한 뒤 작업 완료를 기다려 결과를 저장해야 합니다. 이 글은 전체 REST 작업을 먼저 설명하고, 이어서 목표 FPS, 움직임 왜곡, 편집점, 오디오 싱크를 확인합니다.

Runway는 2026년 9월 17일 Runway Dev에 enhance_frame_rate를 추가했습니다. 비디오 업스케일 엔드포인트를 사용하며 24, 25, 30, 48, 50, 60, 120, 23_98(23.98 fps), 29_97(29.97 fps), 59_94(59.94 fps)를 지원합니다. 입력은 최대 300초이고, 출시 안내에는 2초당 1 credit로 과금한다고 나와 있습니다.

단지 더 부드러워 보인다는 이유로 통과시키지 마세요. 먼저 납품처가 요구하는 정확한 프레임 레이트를 고른 뒤 메타데이터, 위험한 움직임, 편집점, 시작·중간·끝의 오디오 싱크를 확인하고 실제 타임라인과 최종 플랫폼에서 다시 시험해야 합니다.

먼저 납품 프레임 레이트를 정하고, 정확한 사양이 없으면 멈춘다

제출하기 전에 편집 타임라인, 방송 규격, 광고 플랫폼 또는 고객 납품 사양에서 정확한 값을 고르세요. 29_97과 30, 59_94와 60은 비슷해 보이지만 장편, 방송, 혼합 소스 작업에서 잘못 바꾸면 다시 트랜스코딩하거나 전체를 재처리해야 할 수 있습니다.

목표 값일반적인 판단 기준납품 전에 확인할 사항
23_98 / 24타임라인이나 고객이 영화 계열 프레임 레이트를 명시함요구 값이 정수 24가 아니라 정확히 23.98인지
25 / 5025/50 fps 제작 체계 또는 지역별 납품 규격타임라인, 자막, 오디오, 다른 소스도 같은 기준을 쓰는지
29_97 / 30하위 시스템이 둘 중 하나를 명시함하나를 다른 값으로 임의 대체하지 않는지
59_94 / 60움직임이 많은 콘텐츠 또는 고프레임 레이트를 명시한 플랫폼더 부드러워져도 잃어버린 디테일이 복원된 것은 아니라는 점
48 / 120특정 타임라인, 슬로모션 작업, 고프레임 레이트 납품하위 작업이 실제로 요구할 때만 사용하며 높을수록 좋다고 가정하지 않는지

요청이 단지 “더 부드럽게 만들어 달라”는 수준이라면 최종 납품 규격을 먼저 확인해야 합니다. 그렇지 않으면 기술적으로 정상인 60 fps 파일을 만들고도 59.94 fps 타임라인에는 맞지 않을 수 있습니다.

원본 기준값을 남겨야 문제 발생 지점을 찾을 수 있다

처리 전에 원본의 프레임 레이트, 길이, 코덱, 오디오 트랙을 기록하세요. 기준이 없으면 누락된 트랙, 바뀐 길이, 끝부분의 정지 프레임이 원본, Runway 출력, 후속 트랜스코딩 중 어디서 생겼는지 판단하기 어렵습니다.

  1. 원본 파일명, 길이, 해상도, 코덱, 원래 프레임 레이트.
  2. 원본이 고정 프레임 레이트(CFR)인지 가변 프레임 레이트(VFR)인지.
  3. 오디오 트랙 수, 샘플 레이트, 채널 수, 대략적인 길이.
  4. 목표 프레임 레이트와 그 요구 사항의 출처.
  5. 빠른 패닝, 손, 가는 선, 가림 경계, 플래시, 전환, 자막, UI 오버레이처럼 위험도가 높은 타임코드 3~5개.
  6. 시작, 중간, 끝 구간에 최소 하나씩 잡은 오디오 싱크 기준점 3개 이상.

이 기준값이 있으면 길이가 달라지거나 트랙이 빠졌거나 납품 규격에 맞지 않는 문제를 놓친 채 “더 부드러워 보인다”는 이유로 검수를 끝내는 일을 막을 수 있습니다.

먼저 로컬 영상이 이 흐름에 들어갈 수 있는지 확인한다

API를 호출하기 전에 형식, 길이, 크기를 확인하세요. enhance_frame_rate 입력은 최대 300초이며 ephemeral upload 파일은 512바이트 이상 200 MB 이하여야 합니다. MP4/H.264, H.265, AV1처럼 공식 지원 컨테이너와 코덱을 우선 사용하고, 더 긴 영상은 자연스러운 컷에서 나눠 처리합니다.

영상이 이미 object storage에 있다면 HTTPS URL을 videoUri에 직접 넣을 수 있습니다. URL은 IP가 아닌 도메인을 사용하고 HEAD를 지원하며 올바른 Content-Type과 Content-Length를 반환하고 리디렉션에 의존하지 않아야 합니다. URL 방식 영상 한도는 32 MB입니다. 일반적인 로컬 마스터라면 ephemeral upload가 이런 호스팅 조건을 피하기 쉽습니다.

계정에 구매한 credits가 있는지도 확인하세요. 출시 안내는 2초당 1 credit라고 설명하지만 부분 구간의 반올림은 밝히지 않습니다. 제출 응답의 estimatedCost와 완료 후 task의 cost를 기록으로 보관합니다.

이 스크립트로 업로드, 제출, 대기, 다운로드를 완료한다

REST 예시는 중요한 단계를 SDK helper 안에 숨기지 않습니다. API Key를 화면에 표시하지 않고 읽고, 로컬 길이와 크기를 검사하며, ephemeral upload를 만든 뒤 파일을 전송하고 작업을 제출합니다. 이후 5초마다 상태를 확인하고 성공한 출력을 다운로드합니다.

Python 의존성을 설치하고 ffprobe가 있는지 확인하세요.

python3 -m pip install requests

다음을 runway_fps.py로 저장합니다.

from __future__ import annotations

import getpass, json, os, random, subprocess, sys, time
from pathlib import Path
import requests

API = "https://api.dev.runwayml.com"
FPS = {"24", "25", "30", "48", "50", "60", "120", "23_98", "29_97", "59_94"}
RETRYABLE = {429, 502, 503, 504}


def api(session, method, path, body=None):
    for attempt in range(6):
        response = session.request(method, API + path, json=body, timeout=60)
        if response.status_code < 400:
            return response
        if response.status_code in RETRYABLE and attempt < 5:
            time.sleep((2**attempt) * (1 + random.random() * 0.5))
            continue
        raise RuntimeError(f"HTTP {response.status_code}: {response.text}")
    raise RuntimeError("RETRY_LIMIT_REACHED")


def main():
    if len(sys.argv) not in {3, 4}:
        raise SystemExit("python runway_fps.py INPUT_VIDEO TARGET_FPS [OUTPUT_VIDEO]")

    source = Path(sys.argv[1])
    target = sys.argv[2]
    output = Path(sys.argv[3]) if len(sys.argv) == 4 else Path(f"runway-{target}fps.mp4")

    if target not in FPS:
        raise SystemExit(f"UNSUPPORTED_TARGET_FRAMERATE: {target}")
    if not source.is_file():
        raise SystemExit(f"INPUT_NOT_FOUND: {source}")
    if not 512 <= source.stat().st_size <= 200 * 1024 * 1024:
        raise SystemExit(f"INVALID_UPLOAD_SIZE_BYTES: {source.stat().st_size}")

    duration = float(subprocess.run(
        ["ffprobe", "-v", "error", "-show_entries", "format=duration",
         "-of", "default=noprint_wrappers=1:nokey=1", str(source)],
        check=True, capture_output=True, text=True,
    ).stdout.strip())
    if not 0 < duration <= 300:
        raise SystemExit(f"INVALID_DURATION_SECONDS: {duration}")

    key = os.getenv("RUNWAYML_API_SECRET") or getpass.getpass("RUNWAYML_API_SECRET: ")
    session = requests.Session()
    session.headers.update({
        "Authorization": f"Bearer {key}",
        "X-Runway-Version": "2024-11-06",
        "Content-Type": "application/json",
    })

    upload_init = api(session, "POST", "/v1/uploads", {
        "filename": source.name,
        "type": "ephemeral",
    }).json()
    with source.open("rb") as handle:
        upload = requests.post(
            upload_init["uploadUrl"],
            data=upload_init["fields"],
            files={"file": (source.name, handle)},
            timeout=300,
        )
    if upload.status_code >= 400:
        raise RuntimeError(
            f"UPLOAD_FAILED_REQUEST_NEW_UPLOAD: HTTP {upload.status_code}: {upload.text}"
        )

    created = api(session, "POST", "/v1/video_upscale", {
        "model": "enhance_frame_rate",
        "videoUri": upload_init["runwayUri"],
        "targetFramerate": target,
    }).json()
    task_id = created["id"]
    print(json.dumps({"id": task_id, "estimatedCost": created.get("estimatedCost")}, indent=2))

    while True:
        task = api(session, "GET", f"/v1/tasks/{task_id}").json()
        status = task["status"]
        if status in {"PENDING", "THROTTLED", "RUNNING"}:
            time.sleep(5)
            continue
        if status == "SUCCEEDED":
            urls = task.get("output") or []
            if not urls:
                raise RuntimeError("SUCCEEDED_WITHOUT_OUTPUT")
            with requests.get(urls[0], stream=True, timeout=300) as download:
                download.raise_for_status()
                with output.open("wb") as saved:
                    for chunk in download.iter_content(1024 * 1024):
                        if chunk:
                            saved.write(chunk)
            break
        if status == "FAILED":
            raise RuntimeError(json.dumps({
                "status": status,
                "failure": task.get("failure"),
                "failureCode": task.get("failureCode"),
                "cost": task.get("cost"),
            }, ensure_ascii=False))
        if status == "CANCELLED":
            raise RuntimeError(json.dumps({"status": status, "cost": task.get("cost")}))
        raise RuntimeError(f"UNKNOWN_TASK_STATUS: {status}")

    subprocess.run([
        "ffprobe", "-v", "error", "-show_entries",
        "stream=codec_name,width,height,r_frame_rate,avg_frame_rate,time_base,duration:format=duration",
        "-of", "json", str(output),
    ], check=True)
    print(output.resolve())


if __name__ == "__main__":
    main()

input.mp4를 60 fps로 변환해 output-60fps.mp4로 저장하는 예시입니다.

python3 runway_fps.py input.mp4 60 output-60fps.mp4

터미널에 RUNWAYML_API_SECRET:가 표시될 때 키를 입력하세요. 값은 보이지 않고 shell history에 남지 않으며 스크립트도 파일에 쓰지 않습니다. 같은 환경 변수가 안전하게 설정되어 있다면 그 값을 사용합니다.

스크립트가 호출하는 세 API 단계를 이해한다

세 단계가 모두 성공해야 생성이 끝납니다. 필드 이름은 대소문자를 구분하며 REST JSON에서는 videoUri와 targetFramerate를 사용해야 합니다.

단계요청필수 내용성공 신호
업로드 초기화POST https://api.dev.runwayml.com/v1/uploadsfilename, type: "ephemeral"uploadUrl, fields, runwayUri 반환
프레임 레이트 제출POST https://api.dev.runwayml.com/v1/video_upscalemodel: "enhance_frame_rate", videoUri, targetFrameratetask id, estimatedCost 반환
task 조회GET https://api.dev.runwayml.com/v1/tasks/{id}경로의 task IDstatus: "SUCCEEDED" 및 비어 있지 않은 output

초기화 후 uploadUrl로 multipart POST를 보내고 반환된 모든 fields 값을 그대로 포함하며 영상을 file 필드로 첨부합니다. 이 전송이 성공해야 runwayUri를 처리 요청에 쓸 수 있습니다. URI는 24시간 유효합니다.

성공하면 즉시 출력 파일을 내려받아 영구 저장한다

PENDING, THROTTLED, RUNNING이면 계속 기다립니다. Runway는 같은 task에서 5초보다 자주 업데이트를 기대하지 말라고 설명합니다. output[0]은 SUCCEEDED일 때만 읽고, FAILED와 CANCELLED는 실패 종결 상태로 처리합니다.

출력 URL은 보통 24~48시간 안에 만료되므로 즉시 자체 영구 저장소에 내려받고 임시 URL을 최종 납품 링크로 넘기지 마세요. 만료되면 새 유료 생성을 시작하기 전에 같은 task를 다시 조회해 새 URL을 받습니다. 다운로드 성공은 API 작업 완료만 뜻하며 아래 검수는 여전히 필요합니다.

모든 실패를 무조건 재시도하지 말고 종류별로 처리한다

uploadUrl로 보낸 multipart POST가 실패하면 같은 presigned upload를 다시 쓰지 말고 /v1/uploads를 새로 호출하세요. 400, 401, 404, 405는 입력, 키, 리소스, 메서드를 먼저 고칩니다. 예제는 429, 502, 503, 504만 지수 backoff와 jitter로 재시도합니다.

Task가 FAILED이면 failure, failureCode, cost를 저장합니다. SAFETY.*는 재시도하지 않고, ASSET.INVALID는 영상 속성을 고친 뒤 다시 제출하며, INTERNAL.BAD_OUTPUT.*는 입력 문제를 먼저 확인합니다. INPUT_PREPROCESSING.INTERNAL, INTERNAL, 코드 없음, THIRD_PARTY.UNAVAILABLE는 기다린 뒤 재시도할 수 있습니다. 같은 요청을 무한 반복하지 마세요.

1단계: ffprobe로 파일이 실제 목표를 충족하는지 확인한다

화면을 보기 전에 ffprobe로 평균 레이트, 타임 베이스, 실제 프레임 수, 길이, 오디오 스트림을 확인하세요. Finder, 파일 탐색기 또는 플레이어의 단일 FPS 표시는 인수 기준이 될 수 없습니다.

ffprobe -v error -select_streams v:0 \
  -show_entries stream=codec_name,width,height,r_frame_rate,avg_frame_rate,time_base,duration \
  -of json output.mp4
ffprobe -v error -select_streams v:0 -count_frames \
  -show_entries stream=nb_read_frames,avg_frame_rate,r_frame_rate,duration \
  -of json output.mp4
ffprobe -v error \
  -show_entries format=duration:stream=index,codec_type,codec_name,sample_rate,channels,duration \
  -of json output.mp4

다음 항목을 확인합니다.

  • avg_frame_rate가 목표 값 또는 그와 같은 유리수 표현인지.
  • r_frame_rate와 avg_frame_rate가 설명 없이 크게 충돌하지 않는지. 큰 차이가 있으면 VFR 여부를 조사해야 합니다.
  • 거의 CFR인 파일이라면 nb_read_frames가 대략 “길이×목표 fps”에 가까운지.
  • 출력 길이가 원본과 일치하고 끝부분 프레임 누락이나 불필요한 정지 꼬리가 없는지.
  • 해상도, 코덱, 오디오 스트림이 납품 사양에 맞는지.
  • 오디오 스트림 길이가 비디오 스트림 길이와 예상 밖으로 다르지 않은지.

29.97과 59.94는 도구에서 30000/1001, 60000/1001 같은 분수로 표시될 수 있습니다. 분수 표기 자체는 실패가 아닙니다.

2단계: 결함이 생기기 쉬운 장면부터 검수한다

빠른 움직임, 가림 경계, 작은 글자, 편집점부터 보세요. 보간 결함이 가장 빨리 드러나는 구간이므로 다음 장면을 100% 배율에서 프레임 단위 또는 느린 속도로 확인합니다.

  • 빠른 패닝, 트래킹 숏, 빠르게 움직이는 물체.
  • 손, 손가락, 머리카락, 안경테, 입술.
  • 울타리, 블라인드, 격자, 작은 글자, 가는 UI 선.
  • 전경 물체가 배경의 경계를 가로지르거나 가려진 배경을 드러내는 장면.
  • 물, 연기, 입자, 나뭇잎, 고주파 질감.
  • 플래시, 하드 컷, 디졸브, 숏 전환 전후의 프레임.

막연한 선명함이 아니라 재현 가능한 결함을 찾습니다. 이중 윤곽, 고스팅, 휘어진 경계, 한 프레임 동안 사라지는 물체, 맥동하는 질감, 변형된 팔다리, 컷에서 섞인 프레임, 고정 글자의 떨림 등이 여기에 해당합니다.

문제를 발견하면 정확한 타임코드, 목표 레이트, 원본 구간, 출력 구간을 기록합니다. 이렇게 해야 원본에 이미 있던 결함인지, 새로 생긴 문제인지, 재생 디코더 차이인지 구분할 수 있습니다.

3단계: 시작·중간·끝에서 오디오 싱크를 확인한다

시작 부분이 맞는다고 전체가 동기화된 것은 아닙니다. 고정 지연과 시간이 갈수록 커지는 드리프트를 구분하려면 시작·중간·끝을 다음 순서로 확인하세요.

  1. 시작 부분에서 박수, 파열음, 충격, 착지, 화면 컷처럼 명확한 기준점을 찾습니다.
  2. 중간과 끝에서도 같은 검사를 반복합니다.
  3. 세 지점에서 비슷한 오차가 보이면 고정 지연일 가능성이 높습니다.
  4. 뒤로 갈수록 오차가 커지면 길이, 타임 베이스, 프레임 레이트 해석 문제를 의심합니다.
  5. 립싱크 영상은 한 음절만 보지 말고 이어지는 발화의 시작, 중간, 끝을 확인합니다.

출시 안내는 오디오 처리 방식을 설명하지 않습니다. 오디오 트랙이 항상 변경 없이 보존되거나 자동으로 동기화된다고 가정하지 말고 실제 납품 파일로 판단해야 합니다.

4단계: 실제 타임라인과 최종 플랫폼에서 다시 시험한다

실제 편집 타임라인과 최종 플랫폼에서 반드시 시험하세요. NLE나 2차 트랜스코딩이 프레임 레이트를 다시 해석하거나 속도를 바꾸거나 오디오 트랙을 잃을 수 있으므로 최소한 다음 두 가지를 확인합니다.

  • 실제 편집 타임라인에 파일을 놓고 NLE가 프레임 레이트를 다시 해석하거나 속도를 바꾸거나 오디오 트랙을 잃지 않는지 확인합니다.
  • 최종 플랫폼이나 재생 환경에서 시험하고, 2차 트랜스코딩으로 프레임 레이트, 자막, 싱크가 바뀌지 않았는지 확인합니다.

플랫폼이 다시 인코딩한다면 Runway 출력 파일과 플랫폼이 만든 파일을 모두 보관하고 각각 검사합니다. 문제를 원본 출력 탓으로 돌리기 전에 두 파일을 비교해야 합니다.

이 표로 통과·재처리·원본 유지 여부를 결정한다

핵심 항목이 모두 납품 사양을 충족할 때만 파일을 통과시키세요. 한 장면만 실패했다면 전체 영상을 다시 돌리기 전에 해당 구간만 재처리하거나 원본 장면을 유지합니다.

검사 항목합격 조건실패 시 조치
목표 프레임 레이트정확한 납품 값이며 29.97/59.94를 30/60으로 잘못 표시하지 않음목표 값 또는 타임라인 해석을 수정함
길이와 프레임 수원본과 길이가 일치하고 CFR 프레임 수가 예상치에 가까움VFR, 잘림, 정지 꼬리, 타임 베이스를 조사함
해상도와 코덱편집자 또는 채널 요구 사항을 충족함납품 규격에 맞게 리랩 또는 트랜스코딩함
빠른 움직임허용할 수 없는 이중상, 휘어짐, 물체 소실이 없음타임코드를 표시하고 다른 목표 값을 시도하거나 원본 구간을 유지함
컷과 플래시편집점 주변에 혼합, 반복, 비정상 점멸 프레임이 없음자연스러운 컷에서 나누고 재처리한 뒤 접합부를 확인함
글자와 UI글리프, 가는 선, 고정 오버레이가 안정적임화면과 함께 처리하지 말고 후반 작업에서 그래픽을 다시 올림
오디오 싱크시작, 중간, 끝에서 눈에 띄는 오프셋이나 드리프트가 없음스트림 길이와 타임 베이스를 비교하고 재정렬 또는 변환함
파일 무결성전체 디코딩에 성공하고 끝부분과 오디오 트랙이 온전함다시 다운로드하거나 리랩하거나 작업을 재실행함

이런 경우에는 60에서 120 fps로 올리지 않는다

120 fps가 파일 크기와 후속 처리 부담만 늘린다면 납품 조건을 만족하는 더 낮은 값에서 멈추세요. 다음 경우에는 프레임 레이트를 올리지 않습니다.

  • 하위 납품 사양이 24, 25, 29.97, 30 fps 중 하나만 요구함.
  • 원본에 심한 고스팅, 압축 블록, 모션 블러가 이미 있음.
  • 자막, UI, 가는 선이 더 불안정해짐.
  • 오디오 싱크 문제의 원인을 아직 설명하지 못함.
  • 목표 플랫폼이 더 낮은 프레임 레이트로 강제 변환함.
  • 높은 레이트 버전이 눈에 띄는 이점 없이 저장 공간, 디코딩, 후속 트랜스코딩 부담만 늘림.

프레임 레이트는 납품 파라미터이지 단독 품질 점수가 아닙니다. 합격 기준은 “숫자가 가장 크다”가 아니라 “요구된 레이트에 맞고 허용할 수 없는 새 결함이 없다”입니다.

원본부터 납품까지 10단계로 마무리한다

재작업을 줄이는 순서는 사양과 기준, 업로드와 제출, 대기와 저장, 그다음 기술·화면·실환경 검수입니다.

  1. 하위 사양에서 정확한 targetFramerate를 선택합니다.
  2. ffprobe로 원본 레이트, 길이, 오디오, 위험 timecode를 저장하고 300초 이하인지 확인합니다.
  3. 로컬 파일은 POST /v1/uploads를 호출하고 uploadUrl, fields, runwayUri를 저장합니다.
  4. uploadUrl로 multipart form을 보내고 실패하면 새 업로드를 요청합니다.
  5. model, videoUri, targetFramerate로 POST /v1/video_upscale를 호출합니다.
  6. task id와 estimatedCost를 저장합니다.
  7. 종결 상태까지 5초마다 GET /v1/tasks/{id}를 호출합니다.
  8. SUCCEEDED이면 output을 내려받아 영구 저장하고 FAILED나 CANCELLED는 오류 유형별로 처리합니다.
  9. ffprobe와 프레임 검수로 실제 레이트, 길이, 왜곡, 편집점, 오디오 싱크를 확인합니다.
  10. 납품 전에 실제 타임라인과 최종 플랫폼에서 시험합니다.

공식 자료: Models, Inputs, Uploads, Video upscale API Reference, Task API Reference, Outputs, HTTP errors, Task failures, API Changelog. 인터페이스 필드와 단계는 2026년 9월 26일에 확인했습니다.

LLM 워크플로를 최적화할 준비가 되셨나요?

하나의 API로 모델을 연결하고 키와 AI 비용을 관리하세요.

무료로 시작하기