> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vmeg.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 素材上传

> 预签名与分片 CDN 上传

素材通过 **预签名 URL 直传 CDN/对象存储**，再调用 VMEG API 完成注册。文件字节 **不经过** API 网关。

本流程中所有相关 **POST** 均须携带 [`X-Idempotency-Key`](/zh/guides/idempotency)。

<Warning>
  获取预签名 URL 或分片发起等前置步骤可能已返回 `materialId`。该 ID **仅用于续传/完成上传**，在整套上传流程结束前 **不要** 把它传给 create-async 或其他产品接口。只有在单文件 **complete**、分片 **complete**，或瞬时上传/去重响应中已返回已注册 `material` 之后，该 `materialId` 才能用于建任务。
</Warning>

## 选择上传方式

| 方式                | 适用场景        |
| ----------------- | ----------- |
| **单文件（预签名 URL）**  | 较小文件，一次 PUT |
| **分片（Multipart）** | 大文件、可断点续传   |

请求与响应字段：[获取预签名 URL](/api-reference/assets/materials/gen-upload-url)、[完成单文件上传](/api-reference/assets/materials/complete-upload)，以及 [API 参考](/zh/api-reference/introduction) 中 **Assets - Materials → upload** 下的其他接口。

## 单文件上传

<Steps>
  <Step title="获取预签名 URL">
    `POST /openapi/v1/assets/material/upload/gen-upload-url`，传入 `fileHash`（MD5）与 `fileName`。
  </Step>

  <Step title="PUT 到 CDN">
    若响应含 `data.material`，表示文件已去重，无需 PUT；否则将字节上传到 `data.uploadUrl`。
  </Step>

  <Step title="完成上传">
    `POST /openapi/v1/assets/material/upload/complete`，使用 gen-upload-url 返回的 `s3Uri`、`materialId`、`fileHash`、`fileName`。
  </Step>
</Steps>

### 示例

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from "node:crypto";
  import fs from "node:fs";

  const API_BASE = "https://api.vmeg.ai";
  const API_KEY = process.env.VMEG_API_KEY;
  const FILE_PATH = "./demo.mp4";

  function md5File(path) {
    const hash = crypto.createHash("md5");
    hash.update(fs.readFileSync(path));
    return hash.digest("hex");
  }

  function idemKey(step) {
    return `upload-${step}-${crypto.randomUUID()}`;
  }

  async function apiPost(path, body) {
    const res = await fetch(`${API_BASE}${path}`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${API_KEY}`,
        "Content-Type": "application/json",
        "X-Idempotency-Key": idemKey(path),
      },
      body: JSON.stringify(body),
    });
    const json = await res.json();
    if (json.code !== 200) throw new Error(json.message || "API error");
    return json.data;
  }

  async function uploadSingleFile() {
    const fileName = FILE_PATH.split("/").pop();
    const fileHash = md5File(FILE_PATH);
    const fileBytes = fs.readFileSync(FILE_PATH);

    const gen = await apiPost("/openapi/v1/assets/material/upload/gen-upload-url", {
      fileHash,
      fileName,
    });

    if (gen.material?.materialId) {
      console.log("Instant upload (dedup):", gen.material.materialId);
      return gen.material.materialId;
    }

    const putRes = await fetch(gen.uploadUrl, { method: "PUT", body: fileBytes });
    if (!putRes.ok) throw new Error(`CDN PUT failed: ${putRes.status}`);

    const material = await apiPost("/openapi/v1/assets/material/upload/complete", {
      s3Uri: gen.s3Uri,
      materialId: gen.materialId,
      fileHash,
      fileName,
    });

    console.log("materialId:", material.materialId);
    return material.materialId;
  }

  uploadSingleFile().catch(console.error);
  ```

  ```python Python theme={null}
  import hashlib
  import os
  import uuid

  import requests

  API_BASE = "https://api.vmeg.ai"
  API_KEY = os.environ["VMEG_API_KEY"]
  FILE_PATH = "./demo.mp4"


  def md5_file(path: str) -> str:
      h = hashlib.md5()
      with open(path, "rb") as f:
          for chunk in iter(lambda: f.read(1024 * 1024), b""):
              h.update(chunk)
      return h.hexdigest()


  def idem_key(step: str) -> str:
      return f"upload-{step}-{uuid.uuid4()}"


  def api_post(path: str, body: dict) -> dict:
      res = requests.post(
          f"{API_BASE}{path}",
          headers={
              "Authorization": f"Bearer {API_KEY}",
              "Content-Type": "application/json",
              "X-Idempotency-Key": idem_key(path),
          },
          json=body,
          timeout=120,
      )
      res.raise_for_status()
      payload = res.json()
      if payload.get("code") != 200:
          raise RuntimeError(payload.get("message") or "API error")
      return payload["data"]


  def upload_single_file() -> str:
      file_name = os.path.basename(FILE_PATH)
      file_hash = md5_file(FILE_PATH)

      gen = api_post(
          "/openapi/v1/assets/material/upload/gen-upload-url",
          {"fileHash": file_hash, "fileName": file_name},
      )

      if gen.get("material", {}).get("materialId"):
          material_id = gen["material"]["materialId"]
          print("Instant upload (dedup):", material_id)
          return material_id

      with open(FILE_PATH, "rb") as f:
          put = requests.put(gen["uploadUrl"], data=f, timeout=600)
      put.raise_for_status()

      material = api_post(
          "/openapi/v1/assets/material/upload/complete",
          {
              "s3Uri": gen["s3Uri"],
              "materialId": gen["materialId"],
              "fileHash": file_hash,
              "fileName": file_name,
          },
      )
      material_id = material["materialId"]
      print("materialId:", material_id)
      return material_id


  if __name__ == "__main__":
      upload_single_file()
  ```

  ```java Java theme={null}
  import java.io.IOException;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.nio.file.Files;
  import java.nio.file.Path;
  import java.security.MessageDigest;
  import java.time.Duration;
  import java.util.Map;
  import java.util.UUID;

  import com.fasterxml.jackson.databind.JsonNode;
  import com.fasterxml.jackson.databind.ObjectMapper;

  public final class SingleMaterialUpload {
    private static final String API_BASE = "https://api.vmeg.ai";
    private static final String API_KEY = System.getenv("VMEG_API_KEY");
    private static final Path FILE_PATH = Path.of("demo.mp4");

    private static final HttpClient HTTP = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(30))
        .build();
    private static final ObjectMapper JSON = new ObjectMapper();

    public static void main(String[] args) throws Exception {
      String materialId = uploadSingleFile();
      System.out.println("materialId: " + materialId);
    }

    static String uploadSingleFile() throws Exception {
      String fileName = FILE_PATH.getFileName().toString();
      String fileHash = md5File(FILE_PATH);
      byte[] fileBytes = Files.readAllBytes(FILE_PATH);

      JsonNode gen = apiPost(
          "/openapi/v1/assets/material/upload/gen-upload-url",
          Map.of("fileHash", fileHash, "fileName", fileName));

      if (gen.hasNonNull("material") && gen.get("material").hasNonNull("materialId")) {
        return gen.get("material").get("materialId").asText();
      }

      HttpRequest put = HttpRequest.newBuilder()
          .uri(URI.create(gen.get("uploadUrl").asText()))
          .PUT(HttpRequest.BodyPublishers.ofByteArray(fileBytes))
          .timeout(Duration.ofMinutes(10))
          .build();
      HttpResponse<Void> putRes = HTTP.send(put, HttpResponse.BodyHandlers.discarding());
      if (putRes.statusCode() < 200 || putRes.statusCode() >= 300) {
        throw new IOException("CDN PUT failed: " + putRes.statusCode());
      }

      JsonNode material = apiPost(
          "/openapi/v1/assets/material/upload/complete",
          Map.of(
              "s3Uri", gen.get("s3Uri").asText(),
              "materialId", gen.get("materialId").asText(),
              "fileHash", fileHash,
              "fileName", fileName));

      return material.get("materialId").asText();
    }

    static JsonNode apiPost(String path, Map<String, String> body) throws Exception {
      String bodyJson = JSON.writeValueAsString(body);
      HttpRequest req = HttpRequest.newBuilder()
          .uri(URI.create(API_BASE + path))
          .timeout(Duration.ofSeconds(120))
          .header("Authorization", "Bearer " + API_KEY)
          .header("Content-Type", "application/json")
          .header("X-Idempotency-Key", "upload-" + path + "-" + UUID.randomUUID())
          .POST(HttpRequest.BodyPublishers.ofString(bodyJson))
          .build();
      HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
      JsonNode root = JSON.readTree(res.body());
      if (root.path("code").asInt() != 200) {
        throw new IOException(root.path("message").asText("API error"));
      }
      return root.get("data");
    }

    static String md5File(Path path) throws Exception {
      MessageDigest md5 = MessageDigest.getInstance("MD5");
      md5.update(Files.readAllBytes(path));
      StringBuilder sb = new StringBuilder();
      for (byte b : md5.digest()) {
        sb.append(String.format("%02x", b));
      }
      return sb.toString();
    }
  }
  ```
</CodeGroup>

<Note>
  Java 示例使用 Jackson（`ObjectMapper`）处理 JSON。请在项目中添加 `com.fasterxml.jackson.core:jackson-databind`，或改用你熟悉的 JSON 库解析响应。
</Note>

## 分片上传

大文件流程：

1. `POST .../multipart/initiate`
2. `POST .../multipart/presign-parts` — presigned URL per part
3. PUT each part to CDN and record each part `ETag`
4. `POST .../multipart/complete` — finalize and get `materialId`
5. On failure: `POST .../multipart/abort`

分片大小请使用 initiate 响应中的 `recommendedPartSize`（S3 单片通常至少 5 MB，最后一片除外）。

### 示例

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from "node:crypto";
  import fs from "node:fs";

  const API_BASE = "https://api.vmeg.ai";
  const API_KEY = process.env.VMEG_API_KEY;
  const FILE_PATH = "./large.mp4";

  function md5File(path) {
    const hash = crypto.createHash("md5");
    hash.update(fs.readFileSync(path));
    return hash.digest("hex");
  }

  function idemKey(step) {
    return `multipart-${step}-${crypto.randomUUID()}`;
  }

  async function apiPost(path, body) {
    const res = await fetch(`${API_BASE}${path}`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${API_KEY}`,
        "Content-Type": "application/json",
        "X-Idempotency-Key": idemKey(path),
      },
      body: JSON.stringify(body),
    });
    const json = await res.json();
    if (json.code !== 200) throw new Error(json.message || "API error");
    return json.data;
  }

  function readPart(buffer, offset, size) {
    return buffer.subarray(offset, Math.min(offset + size, buffer.length));
  }

  async function uploadMultipart() {
    const fileName = FILE_PATH.split("/").pop();
    const fileHash = md5File(FILE_PATH);
    const fileBytes = fs.readFileSync(FILE_PATH);

    const init = await apiPost("/openapi/v1/assets/material/upload/multipart/initiate", {
      fileHash,
      fileName,
    });

    const partSize = init.recommendedPartSize || 8 * 1024 * 1024;
    const partNumbers = [];
    for (let offset = 0, n = 1; offset < fileBytes.length; offset += partSize, n++) {
      partNumbers.push(n);
    }

    const presign = await apiPost(
      "/openapi/v1/assets/material/upload/multipart/presign-parts",
      {
        materialId: init.materialId,
        uploadId: init.uploadId,
        fileHash,
        fileName,
        partNumbers,
      }
    );

    const parts = [];
    for (const { partNumber, url } of presign.urls) {
      const start = (partNumber - 1) * partSize;
      const chunk = readPart(fileBytes, start, partSize);
      const putRes = await fetch(url, { method: "PUT", body: chunk });
      if (!putRes.ok) throw new Error(`Part ${partNumber} PUT failed: ${putRes.status}`);
      const eTag = (putRes.headers.get("etag") || "").replace(/"/g, "");
      parts.push({ partNumber, eTag });
    }

    const material = await apiPost(
      "/openapi/v1/assets/material/upload/multipart/complete",
      {
        materialId: init.materialId,
        uploadId: init.uploadId,
        fileHash,
        fileName,
        parts,
      }
    );

    console.log("materialId:", material.materialId);
    return material.materialId;
  }

  uploadMultipart().catch(console.error);
  ```

  ```python Python theme={null}
  import hashlib
  import math
  import os
  import uuid

  import requests

  API_BASE = "https://api.vmeg.ai"
  API_KEY = os.environ["VMEG_API_KEY"]
  FILE_PATH = "./large.mp4"


  def md5_file(path: str) -> str:
      h = hashlib.md5()
      with open(path, "rb") as f:
          for chunk in iter(lambda: f.read(1024 * 1024), b""):
              h.update(chunk)
      return h.hexdigest()


  def idem_key(step: str) -> str:
      return f"multipart-{step}-{uuid.uuid4()}"


  def api_post(path: str, body: dict) -> dict:
      res = requests.post(
          f"{API_BASE}{path}",
          headers={
              "Authorization": f"Bearer {API_KEY}",
              "Content-Type": "application/json",
              "X-Idempotency-Key": idem_key(path),
          },
          json=body,
          timeout=120,
      )
      res.raise_for_status()
      payload = res.json()
      if payload.get("code") != 200:
          raise RuntimeError(payload.get("message") or "API error")
      return payload["data"]


  def upload_multipart() -> str:
      file_name = os.path.basename(FILE_PATH)
      file_hash = md5_file(FILE_PATH)
      file_size = os.path.getsize(FILE_PATH)

      init = api_post(
          "/openapi/v1/assets/material/upload/multipart/initiate",
          {"fileHash": file_hash, "fileName": file_name},
      )

      part_size = init.get("recommendedPartSize") or 8 * 1024 * 1024
      part_count = max(1, math.ceil(file_size / part_size))
      part_numbers = list(range(1, part_count + 1))

      presign = api_post(
          "/openapi/v1/assets/material/upload/multipart/presign-parts",
          {
              "materialId": init["materialId"],
              "uploadId": init["uploadId"],
              "fileHash": file_hash,
              "fileName": file_name,
              "partNumbers": part_numbers,
          },
      )

      parts = []
      with open(FILE_PATH, "rb") as f:
          for item in presign["urls"]:
              part_number = item["partNumber"]
              f.seek((part_number - 1) * part_size)
              chunk = f.read(part_size)
              put = requests.put(item["url"], data=chunk, timeout=600)
              put.raise_for_status()
              e_tag = put.headers.get("ETag", "").strip('"')
              parts.append({"partNumber": part_number, "eTag": e_tag})

      material = api_post(
          "/openapi/v1/assets/material/upload/multipart/complete",
          {
              "materialId": init["materialId"],
              "uploadId": init["uploadId"],
              "fileHash": file_hash,
              "fileName": file_name,
              "parts": parts,
          },
      )
      material_id = material["materialId"]
      print("materialId:", material_id)
      return material_id


  if __name__ == "__main__":
      upload_multipart()
  ```

  ```java Java theme={null}
  import java.io.IOException;
  import java.io.RandomAccessFile;
  import java.net.URI;
  import java.net.http.HttpClient;
  import java.net.http.HttpRequest;
  import java.net.http.HttpResponse;
  import java.nio.file.Path;
  import java.security.MessageDigest;
  import java.time.Duration;
  import java.util.ArrayList;
  import java.util.List;
  import java.util.Map;
  import java.util.UUID;

  import com.fasterxml.jackson.databind.JsonNode;
  import com.fasterxml.jackson.databind.ObjectMapper;
  import com.fasterxml.jackson.databind.node.ArrayNode;
  import com.fasterxml.jackson.databind.node.ObjectNode;

  public final class MultipartMaterialUpload {
    private static final String API_BASE = "https://api.vmeg.ai";
    private static final String API_KEY = System.getenv("VMEG_API_KEY");
    private static final Path FILE_PATH = Path.of("large.mp4");

    private static final HttpClient HTTP = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(30))
        .build();
    private static final ObjectMapper JSON = new ObjectMapper();

    public static void main(String[] args) throws Exception {
      System.out.println("materialId: " + uploadMultipart());
    }

    static String uploadMultipart() throws Exception {
      String fileName = FILE_PATH.getFileName().toString();
      String fileHash = md5File(FILE_PATH);
      long fileSize = java.nio.file.Files.size(FILE_PATH);

      JsonNode init = apiPost(
          "/openapi/v1/assets/material/upload/multipart/initiate",
          Map.of("fileHash", fileHash, "fileName", fileName));

      long partSize = init.has("recommendedPartSize")
          ? init.get("recommendedPartSize").asLong()
          : 8L * 1024 * 1024;
      int partCount = (int) Math.max(1, (fileSize + partSize - 1) / partSize);

      List<Integer> partNumbers = new ArrayList<>();
      for (int i = 1; i <= partCount; i++) {
        partNumbers.add(i);
      }

      ObjectNode presignBody = JSON.createObjectNode();
      presignBody.put("materialId", init.get("materialId").asText());
      presignBody.put("uploadId", init.get("uploadId").asText());
      presignBody.put("fileHash", fileHash);
      presignBody.put("fileName", fileName);
      ArrayNode nums = presignBody.putArray("partNumbers");
      partNumbers.forEach(nums::add);

      JsonNode presign = apiPost(
          "/openapi/v1/assets/material/upload/multipart/presign-parts", presignBody);

      ArrayNode completedParts = JSON.createArrayNode();
      try (RandomAccessFile raf = new RandomAccessFile(FILE_PATH.toFile(), "r")) {
        for (JsonNode item : presign.get("urls")) {
          int partNumber = item.get("partNumber").asInt();
          long offset = (long) (partNumber - 1) * partSize;
          int len = (int) Math.min(partSize, fileSize - offset);
          byte[] chunk = new byte[len];
          raf.seek(offset);
          raf.readFully(chunk);

          HttpRequest put = HttpRequest.newBuilder()
              .uri(URI.create(item.get("url").asText()))
              .PUT(HttpRequest.BodyPublishers.ofByteArray(chunk))
              .timeout(Duration.ofMinutes(10))
              .build();
          HttpResponse<String> putRes = HTTP.send(put, HttpResponse.BodyHandlers.ofString());
          if (putRes.statusCode() < 200 || putRes.statusCode() >= 300) {
            throw new IOException("Part PUT failed: " + putRes.statusCode());
          }
          String eTag = putRes.headers().firstValue("etag")
              .orElse("").replace("\"", "");

          ObjectNode part = JSON.createObjectNode();
          part.put("partNumber", partNumber);
          part.put("eTag", eTag);
          completedParts.add(part);
        }
      }

      ObjectNode completeBody = JSON.createObjectNode();
      completeBody.put("materialId", init.get("materialId").asText());
      completeBody.put("uploadId", init.get("uploadId").asText());
      completeBody.put("fileHash", fileHash);
      completeBody.put("fileName", fileName);
      completeBody.set("parts", completedParts);

      JsonNode material = apiPost(
          "/openapi/v1/assets/material/upload/multipart/complete", completeBody);

      return material.get("materialId").asText();
    }

    static JsonNode apiPost(String path, Object body) throws Exception {
      String bodyJson = body instanceof String ? (String) body : JSON.writeValueAsString(body);
      HttpRequest req = HttpRequest.newBuilder()
          .uri(URI.create(API_BASE + path))
          .timeout(Duration.ofSeconds(120))
          .header("Authorization", "Bearer " + API_KEY)
          .header("Content-Type", "application/json")
          .header("X-Idempotency-Key", "multipart-" + path + "-" + UUID.randomUUID())
          .POST(HttpRequest.BodyPublishers.ofString(bodyJson))
          .build();
      HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
      JsonNode root = JSON.readTree(res.body());
      if (root.path("code").asInt() != 200) {
        throw new IOException(root.path("message").asText("API error"));
      }
      return root.get("data");
    }

    static JsonNode apiPost(String path, Map<String, String> body) throws Exception {
      return apiPost(path, (Object) JSON.valueToTree(body));
    }

    static String md5File(Path path) throws Exception {
      MessageDigest md5 = MessageDigest.getInstance("MD5");
      md5.update(java.nio.file.Files.readAllBytes(path));
      StringBuilder sb = new StringBuilder();
      for (byte b : md5.digest()) {
        sb.append(String.format("%02x", b));
      }
      return sb.toString();
    }
  }
  ```
</CodeGroup>

## 上传之后

仅在完整上传流程成功后，才能在 [音视频翻译](/zh/guides/products/media-translation) 的 `source.materialId` 中使用已注册的 `materialId`。管理资产见 [素材](/zh/guides/assets/materials)。
