
Byted Byteplus Vod Video Enhancement
- 2 installs
- 408 repo stars
- Updated August 3, 2026
- volcengine/agentkit-samples
Uploads video or audio to BytePlus VOD from a local file or public URL and runs AI quality restoration to remove noise, artifacts, and scratches.
About
Uploads video and audio media to BytePlus VOD storage and returns a vid reference, supporting local and URL-pull uploads. A developer uses it to ingest media and run AI-based comprehensive video quality restoration.
- Supports local upload (ApplyUploadInfo + TOS + CommitUploadInfo) and URL pull upload
- AI restoration removes compression artifacts, noise, and scratches to improve clarity
Byted Byteplus Vod Video Enhancement by the numbers
- 2 all-time installs (skills.sh)
- Ranked #1,168 of 1,337 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/volcengine/agentkit-samples --skill byted-byteplus-vod-video-enhancementAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 408 |
| Last updated | August 3, 2026 |
| Repository | volcengine/agentkit-samples ↗ |
What it does
Uploads video or audio to BytePlus VOD from a local file or public URL and runs AI quality restoration to remove noise, artifacts, and scratches.
Files
VOD_video enhancement
Uploads video/audio to a BytePlus VOD space (from a local file or a public URL) and returns a vid://vxxxx reference. Additionally provides AI-based comprehensive quality restoration that removes compression artifacts, noise, and scratches from ingested videos, improving overall clarity and color rendition.
---
Prerequisites
- Environment variables (required, can be configured via a
.envfile in the working directory — the scripts will load it automatically): BYTEPLUS_ACCESSKEY— BytePlus Access KeyBYTEPLUS_SECRETKEY— BytePlus Secret KeyVOD_SPACE_NAME— VOD space name- Execution: examples use
uv run python ...(if the host environment can run Python directly,python scripts/...also works).
---
Workflow Overview
Upload pipeline (local file):
[S1_APPLY] ApplyUploadInfo → returns TOS upload address + SessionKey
[S2_TOS] PUT file to TOS (direct or chunked)
[S3_COMMIT] CommitUploadInfo → returns Vid
Output: { Vid, Source, PlayURL, FileName, SpaceName, SourceUrl }
Upload pipeline (URL):
[S1_UPLOAD] Submit URL upload job (UploadMediaByUrl) → returns JobId
[S2_POLL] Poll QueryUploadTaskInfo → returns Vid
Output: { Vid, Source, PlayURL, FileName, SpaceName, SourceUrl, JobId }
Quality restoration pipeline:
[S3_ENHANCE] Submit restoration job (StartExecution/enhanceVideo) → returns RunId
[S4_POLL] Poll GetExecution → returns the restored file
Output: { Status, SpaceName, VideoUrls[{ FileId, DirectUrl, Source }] }---
Quick Self-Check (recommended)
Before running any script, confirm the following (avoid unrelated Python/uv version checks):
.envor environment variables contain:BYTEPLUS_ACCESSKEY+BYTEPLUS_SECRETKEYVOD_SPACE_NAME
Once verified, pick the corresponding pipeline based on user intent:
| User intent | Pipeline | Entry script |
|---|---|---|
| Upload video to VOD | Upload pipeline | scripts/upload.py |
| Quality restoration / denoise / remove compression artifacts | Quality restoration pipeline | scripts/quality_enhance.py |
---
S1_UPLOAD & S2_POLL: Upload and Obtain Vid
Calling Convention
Run from the Skill root directory (byted-byteplus-vod-video-enhancement/):
# Local file upload (synchronous — returns Vid when complete)
uv run python scripts/upload.py "/path/to/video.mp4" [space_name]
# URL upload (automatically polls until a Vid is returned)
uv run python scripts/upload.py "<https://example.com/video.mp4>" [space_name]
# Example: specifying the space
uv run python scripts/upload.py "https://example.com/sample.mp4" my_space- First argument: either a local file path or a public
http:///https://link. The script auto-detects which mode to use. - Second argument (optional): the VOD space name; when omitted it is read from the environment variable
VOD_SPACE_NAME. - The file / URL must carry a file extension (such as
.mp4,.mov,.mp3), otherwise an error is raised.
Upload Flow
Local file upload (synchronous, three-step): 1. Call ApplyUploadInfo (API Version: 2023-01-01) to obtain the TOS upload address, authentication token, and SessionKey. 2. PUT the file to TOS (direct upload for files < 20 MiB, chunked upload otherwise). 3. Call CommitUploadInfo (API Version: 2023-01-01) with the SessionKey; returns the Vid.
URL upload (two-phase asynchronous): 1. Call UploadMediaByUrl (API Version: 2023-01-01) to submit the pull job; returns a JobId. 2. Poll QueryUploadTaskInfo until the job completes, with a maximum wait of 30 minutes (360 × 5s). 3. Once the job is complete, return the Vid.
Output Format
On success, a JSON line is printed to stdout:
{
"Vid": "v0d123abc",
"Source": "vid://v0d123abc",
"PlayURL": "https://example.cdn.com/xxx.m3u8",
"PosterUri": "",
"FileName": "uuid-filename.mp4",
"SpaceName": "my_space",
"SourceUrl": "https://example.com/video.mp4",
"JobId": "job-xxx"
}Source: avid://-formatted reference that can be passed directly to follow-up skills such asbyted-mediakit.- The host agent should save the
Sourcefield for use in subsequent processing steps.
Timeout Handling
If polling times out (30 minutes), the output is:
{
"error": "Polling timed out (360 attempts × 5s); the URL pull upload is still processing",
"resume_hint": {
"description": "The URL upload has not finished yet; retry with the command below",
"command": "uv run python scripts/upload.py \"<original URL>\" [space_name]"
},
"JobIds": "job-xxx",
"State": "running"
}---
S3_ENHANCE & S4_POLL: AI Comprehensive Quality Restoration
Calling Convention
Run from the Skill root directory (byted-byteplus-vod-video-enhancement/):
# Submit after the user has explicitly selected both config and repair_style
uv run python scripts/quality_enhance.py '{"type":"Vid","video":"v0310abc","config":"common","repair_style":1}'
# Example: a vid:// prefix is also accepted (the script strips it automatically)
uv run python scripts/quality_enhance.py '{"type":"Vid","video":"vid://v0d225gxxx","config":"common","repair_style":1}' production_space
# Pass parameters via @file.json (recommended — avoids shell escaping issues)
uv run python scripts/quality_enhance.py @params.json
# Resume polling after a timeout
uv run python scripts/poll_execution.py '<RunId>' [space_name]Parameter Reference
| Parameter | Type | Required | Description |
|---|---|---|---|
type | string | ✅ | Vid (video ID) or DirectUrl (VOD storage FileName) |
video | string | ✅ | The video Vid or FileName (a vid:// prefix is accepted and automatically stripped) |
config | string | ✅ | VolcMoeEnhanceParam Config; one of common, ugc, short_series, aigc, old_film. If the user explicitly asks for defaults, use common. |
repair_style | integer | ✅ | VolcMoeEnhanceParam VideoStrategy.RepairStyle; 1 = Standard, 2 = Pro. If the user explicitly asks for defaults, use 1. |
Before quality restoration, you MUST ask the user to choose both required enhancement parameters if either config or repair_style is missing. Do not silently use defaults. Only use config=common and repair_style=1 when the user explicitly asks for default/recommended settings. When asking the user, use plain product language only; do not show internal parameter names or values such as config=..., repair_style=..., common, or short_series in the question text or option labels.
Suggested prompt:
Video enhancement may take some time. Choosing the right template usually gives better results.
What type of video is it?
1. General video
2. Short video / UGC
3. Short drama / short series
4. AI-generated content
5. Old film / classic footage that needs restoration
>
Which video enhancement tier would you like to use?
1. Standard: balanced visual improvement and processing speed
2. Pro: cinematic-grade restoration with longer processing time; allowlist access may be required
If the user asks for a default recommendation, use config=common and repair_style=1. Otherwise, wait for the user's selections before running scripts/quality_enhance.py.
Internal mapping: General video -> config=common; Short video / UGC -> config=ugc; Short drama / short series -> config=short_series; AI-generated content -> config=aigc; Old film / classic footage -> config=old_film; Standard -> repair_style=1; Pro -> repair_style=2. Do not expose these parameter names or values in the question unless the user asks for implementation details.
Special handling for Pro: if the user chooses repair_style=2 and the StartExecution/GetExecution response returns HTTP status 403, or any error message contains Permission denied, explain that Pro is only available to users on the allowlist. Ask the user to submit a ticket to apply: https://console.byteplus.com/workorder/create
Output Format
On success, a JSON line is printed to stdout:
{
"Status": "Success",
"SpaceName": "my_space",
"VideoUrls": [
{
"FileId": "xxx",
"DirectUrl": "path/to/output.mp4",
"Source": "directurl://path/to/output.mp4",
"Url": "https://example.cdn.com/path/to/output.mp4?auth_key=..."
}
],
"AudioUrls": [],
"Texts": []
}VideoUrls[0].Url: a directly accessible/downloadable URL (the script signs it based on the space's domain/auth rules).VideoUrls[0].Source(directurl://...) can be passed directly to downstream skills.
Timeout Handling
If polling times out (30 minutes), the output is:
{
"error": "Polling timed out (360 attempts × 5s); the job is still processing",
"resume_hint": {
"description": "The job has not finished yet; resume polling with the command below",
"command": "uv run python scripts/poll_execution.py '<RunId>' [space_name]"
}
}---
Environment Variables
| Name | Description | Required |
|---|---|---|
BYTEPLUS_ACCESSKEY | BytePlus Access Key | Yes |
BYTEPLUS_SECRETKEY | BytePlus Secret Key | Yes |
VOD_SPACE_NAME | VOD space name | Yes (or via CLI argument) |
VOD_POLL_INTERVAL | Polling interval (seconds, default 5) | No |
VOD_POLL_MAX | Maximum polling attempts (default 360) | No |
VOD_URL_EXPIRE_MINUTES | Signed URL expiration (minutes, default 60) | No |
VOD_PLAY_DOMAIN | Force the use of a specific play domain (optional, highest priority) | No |
---
Error Output Format
All errors share the same format:
{"error": "error description"}---
References
- BytePlus VOD Python SDK
- Quality restoration parameter reference
- API:
ApplyUploadInfo(Version: 2023-01-01) - API:
CommitUploadInfo(Version: 2023-01-01) - API:
UploadMediaByUrl(Version: 2023-01-01) - API:
QueryUploadTaskInfo(Version: 2023-01-01) - API:
StartExecution(Version: 2025-07-01) - API:
GetExecution(Version: 2025-07-01)
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
Comprehensive Quality Restoration quality_enhance
AI-based comprehensive video quality restoration: removes compression artifacts, noise, and scratches, improving overall clarity and color rendition.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
type | string | ✅ | Vid (video ID) or DirectUrl (VOD storage FileName) |
video | string | ✅ | The video Vid or FileName (a vid:// prefix is accepted and automatically stripped) |
config | string | ✅ | VolcMoeEnhanceParam Config; one of common, ugc, short_series, aigc, old_film. If the user explicitly asks for defaults, use common. |
repair_style | integer | ✅ | VolcMoeEnhanceParam VideoStrategy.RepairStyle; 1 = Standard, 2 = Pro. If the user explicitly asks for defaults, use 1. |
config and repair_style are required. If either value is missing from the user's request, ask the user to choose before submitting the job. Do not silently use defaults unless the user explicitly asks for default/recommended settings.
Pro Allowlist Error Handling
If repair_style=2 is used and the StartExecution/GetExecution response returns HTTP status 403, or any error message contains Permission denied, it means the user has not been allowlisted for Pro. Pro is only available to users on the allowlist. Ask the user to submit a ticket to apply: https://console.byteplus.com/workorder/create
Return Value
The job is automatically polled until a terminal state is reached. On success, it returns:
{
"Status": "Success",
"SpaceName": "my_space",
"VideoUrls": [
{
"FileId": "xxx",
"DirectUrl": "path/to/output.mp4",
"Source": "directurl://path/to/output.mp4",
"Url": "https://example.cdn.com/path/to/output.mp4?auth_key=..."
}
],
"AudioUrls": [],
"Texts": []
}Url: the script tries to produce a directly accessible/downloadable URL based on the space's play-domain configuration (it may carry auth parameters).Source(directurl://...) can be passed directly to downstream skills.
If polling times out, the response contains error + resume_hint, whose command can be used to resume polling:
uv run python scripts/poll_execution.py '<RunId>' [space_name]Examples
# Submit after the user has explicitly selected both config and repair_style
uv run python scripts/quality_enhance.py '{"type":"Vid","video":"v0310abc","config":"common","repair_style":1}'
# Use Pro tier with a different Moe config
uv run python scripts/quality_enhance.py '{"type":"Vid","video":"v0310abc","config":"ugc","repair_style":2}'
# Use DirectUrl as input
uv run python scripts/quality_enhance.py '{"type":"DirectUrl","video":"path/to/input.mp4","config":"common","repair_style":1}'
# Pass parameters via @file.json (recommended — avoids shell escaping issues)
uv run python scripts/quality_enhance.py @params.json
# Resume polling after a timeout
uv run python scripts/poll_execution.py 'run-xxx' my_space# BytePlus authentication (preferred)
BYTEPLUS_ACCESSKEY=
BYTEPLUS_SECRETKEY=
# Legacy names (still supported by scripts)
# VOLCENGINE_ACCESS_KEY=
# VOLCENGINE_SECRET_KEY=
# VOD space name (required)
VOD_SPACE_NAME=
#!/usr/bin/env python3
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
poll_execution.py — resume polling an enhanceVideo job by RunId
Usage:
uv run python scripts/poll_execution.py <RunId> [space_name]
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from vod_client import get_client, get_space_name, out, bail
from quality_enhance import poll_enhance
def main():
if len(sys.argv) < 2:
bail("Usage: uv run python scripts/poll_execution.py <RunId> [space_name]")
run_id = sys.argv[1].strip()
if not run_id:
bail("RunId must not be empty")
space_name = get_space_name(argv_pos=2)
client = get_client()
out(poll_enhance(client, run_id, space_name))
if __name__ == "__main__":
main()
[project]
name = "byted-byteplus-vod-video-enhancement"
version = "1.0.0"
description = "将媒资从公网 URL 上传到火山引擎 VOD"
requires-python = ">=3.10"
dependencies = [
"requests>=2.31.0",
"python-dotenv>=1.0.0",
]
#!/usr/bin/env python3
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
quality_enhance.py — comprehensive quality restoration
AI-based comprehensive video quality restoration: removes compression artifacts,
noise, and scratches, improving overall clarity and color rendition.
Usage:
uv run python scripts/quality_enhance.py '<json_args>'
uv run python scripts/quality_enhance.py @params.json
See references/quality-enhance.md for the json_args fields.
"""
import json
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from vod_client import get_client, get_space_name, build_media_input, get_play_url_by_filename, out, log, bail
# ── Polling configuration ──────────────────────────────────────────────────
POLL_INTERVAL = float(os.environ.get("VOD_POLL_INTERVAL", "5"))
POLL_MAX = int(os.environ.get("VOD_POLL_MAX", "360")) # 360 × 5s = 30 minutes
# ── VOD API constants ──────────────────────────────────────────────────────
_ACTION_START = "StartExecution"
_ACTION_GET = "GetExecution"
_VERSION_EXEC = "2025-07-01"
_PENDING = {"", "PendingStart", "Running"}
_TERMINAL_FAIL = {"Failed", "Terminated"}
_VALID_MOE_CONFIGS = {"common", "ugc", "short_series", "aigc", "old_film"}
_VALID_REPAIR_STYLES = {1, 2}
def _start_execution(client, payload: dict) -> str:
"""Submit StartExecution and return the RunId."""
resp = client.post(_ACTION_START, _VERSION_EXEC, payload)
result = resp.get("Result", {}) or {}
run_id = result.get("RunId", "")
if not run_id:
bail(f"StartExecution did not return a RunId, response: {resp}")
return run_id
def _get_execution(client, run_id: str) -> dict:
"""Call GetExecution and return a structured result."""
resp = client.get(_ACTION_GET, _VERSION_EXEC, {"RunId": run_id})
result = resp.get("Result", {}) or {}
status = result.get("Status", "")
space_name = (result.get("Meta", {}) or {}).get("SpaceName", "")
if status != "Success":
return {
"Status": status,
"Code": result.get("Code", ""),
"SpaceName": space_name,
}
# Parse the enhanceVideo output
output = ((result.get("Output", {}) or {}).get("Task", {}) or {})
enhance = output.get("Enhance", {}) or {}
store_uri = enhance.get("StoreUri", "")
file_id = enhance.get("FileId", "")
# Extract the FileName from StoreUri (strip the tos://<bucket>/ prefix)
direct_url = ""
if store_uri:
from urllib.parse import urlparse
parsed = urlparse(store_uri)
parts = parsed.path.split("/")[1:]
direct_url = "/".join(parts)
url = ""
if direct_url and space_name:
url = get_play_url_by_filename(client, space_name, direct_url, expired_minutes=int(os.environ.get("VOD_URL_EXPIRE_MINUTES", "60")))
return {
"Status": "Success",
"SpaceName": space_name,
"VideoUrls": [
{
"FileId": file_id,
"DirectUrl": direct_url,
"Source": f"directurl://{direct_url}" if direct_url else "",
"Url": url,
}
],
"AudioUrls": [],
"Texts": [],
}
def poll_enhance(client, run_id: str, space_name: str) -> dict:
"""Poll the enhanceVideo job until a terminal state is reached."""
for i in range(1, POLL_MAX + 1):
log(f"Polling quality restoration job [{i}/{POLL_MAX}] RunId={run_id} ...")
try:
result = _get_execution(client, run_id)
except Exception as exc:
log(f" query exception: {exc}")
time.sleep(POLL_INTERVAL)
continue
status = result.get("Status", "")
if status in _PENDING:
log(f" status={status!r}, waiting {POLL_INTERVAL}s ...")
time.sleep(POLL_INTERVAL)
continue
if status in _TERMINAL_FAIL:
ret = {
"Status": status,
"Code": result.get("Code", ""),
"SpaceName": result.get("SpaceName", space_name),
}
if status == "Failed":
ret["resume_hint"] = {
"description": "The job failed; check the parameters and resubmit, or resume polling with the command below",
"command": f"uv run python scripts/poll_execution.py '{run_id}' {space_name}",
}
else:
ret["note"] = "The job was terminated; please resubmit."
return ret
if status == "Success":
return result
log(f" unknown status={status!r}, continuing to wait ...")
time.sleep(POLL_INTERVAL)
return {
"error": f"Polling timed out ({POLL_MAX} attempts × {POLL_INTERVAL}s); the job is still processing",
"resume_hint": {
"description": "The job has not finished yet; resume polling with the command below",
"command": f"uv run python scripts/poll_execution.py '{run_id}' {space_name}",
},
}
def _parse_moe_config(args: dict) -> str:
if "config" not in args:
bail("quality_enhance: 'config' is required (recommended default: common)")
config = str(args.get("config", "")).strip()
if config not in _VALID_MOE_CONFIGS:
bail(
"quality_enhance: 'config' must be one of "
+ ", ".join(sorted(_VALID_MOE_CONFIGS))
)
return config
def _parse_repair_style(args: dict) -> int:
if "repair_style" not in args:
bail("quality_enhance: 'repair_style' is required (recommended default: 1 / Standard)")
raw = args.get("repair_style")
try:
repair_style = int(raw)
except (TypeError, ValueError):
bail("quality_enhance: 'repair_style' must be 1 (standard) or 2 (pro)")
if repair_style not in _VALID_REPAIR_STYLES:
bail("quality_enhance: 'repair_style' must be 1 (standard) or 2 (pro)")
return repair_style
def main():
if len(sys.argv) < 2:
bail("Usage: uv run python scripts/quality_enhance.py '<json_args>'")
raw = sys.argv[1]
if raw.startswith("@"):
fpath = raw[1:]
if not Path(fpath).is_file():
bail(f"Parameter file does not exist: {fpath}")
with open(fpath, "r", encoding="utf-8") as f:
raw = f.read()
try:
args = json.loads(raw)
except json.JSONDecodeError as e:
bail(f"Failed to parse JSON arguments: {e}")
asset_type = args.get("type", "Vid")
video = args.get("video", "")
if not video:
bail("quality_enhance: the 'video' field must not be empty")
space_name = get_space_name(argv_pos=2)
client = get_client()
media_input = build_media_input(asset_type, video, space_name)
config = _parse_moe_config(args)
repair_style = _parse_repair_style(args)
payload = {
"Input": media_input,
"Operation": {
"Type": "Task",
"Task": {
"Type": "Enhance",
"Enhance": {
"Type": "Moe",
"MoeEnhance": {
"Config": config,
"VideoStrategy": {"RepairStyle": repair_style, "RepairStrength": 0},
},
},
},
},
}
log(f"Submitting quality restoration job, video={video} type={asset_type}")
try:
run_id = _start_execution(client, payload)
except SystemExit:
raise
except Exception as exc:
bail(f"Failed to submit quality restoration job: {exc}")
log(f"Job submitted, RunId={run_id}, starting polling ...")
out(poll_enhance(client, run_id, space_name))
if __name__ == "__main__":
main()
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
TOS media upload — aligned with the official VodService.upload_tob behaviour.
Handles the binary upload step between ApplyUploadInfo and CommitUploadInfo:
1. VPC pre-signed path (vpc direct / vpc part)
2. Candidate upload addresses (main → backup → fallback)
3. Default UploadAddress fallback
Small files (< chunk_size) use direct PUT; larger files use chunked upload
(init → upload parts → merge).
"""
from __future__ import annotations
import json
import os
import time
from typing import Any, Callable, Dict, List, Optional, Tuple
from zlib import crc32
import requests
MIN_CHUNK_SIZE = 1024 * 1024 * 20 # 20 MiB
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
class _FileSectionReader:
"""Readable wrapper over a slice of a file object (for chunked PUT)."""
def __init__(self, fobj, size: int, init_offset: Optional[int] = None):
self.fobj = fobj
self.size = size
self.offset = 0
if init_offset is not None:
self.fobj.seek(init_offset, os.SEEK_SET)
def read(self, amt=None):
if self.offset >= self.size:
return b""
if amt is None or amt < 0 or amt + self.offset >= self.size:
data = self.fobj.read(self.size - self.offset)
self.offset = self.size
return data
self.offset += amt
return self.fobj.read(amt)
@property
def len(self) -> int:
return self.size
def _norm_headers(raw: Any) -> Dict[str, str]:
if isinstance(raw, dict):
return {str(k): str(v) for k, v in raw.items()}
return {}
def _retry(fn: Callable[[], Any], tries: int = 3, delay: float = 1.0, backoff: float = 2.0) -> Any:
last_err: Optional[BaseException] = None
d = delay
for attempt in range(tries):
try:
return fn()
except Exception as exc:
last_err = exc
if attempt == tries - 1:
break
time.sleep(d)
d *= backoff
assert last_err is not None
raise last_err
# ---------------------------------------------------------------------------
# TOS uploader
# ---------------------------------------------------------------------------
class TosUploader:
"""PUT files to the TOS gateway using credentials returned by ApplyUploadInfo."""
def __init__(self, session: Optional[requests.Session] = None):
self._s = session or requests.Session()
# -- low-level helpers --------------------------------------------------
def _put_file(self, url: str, path: str, headers: Dict[str, str]) -> Tuple[bool, bytes]:
with open(path, "rb") as f:
r = self._s.put(url, headers=headers, data=f)
headers["X-Tt-Logid"] = r.headers.get("X-Tt-Logid", "")
return r.status_code == 200, r.content
def _put_data(self, url: str, data: Optional[bytes], headers: Dict[str, str]) -> Tuple[bool, bytes]:
r = self._s.put(url, headers=headers, data=data)
headers["X-Tt-Logid"] = r.headers.get("X-Tt-Logid", "")
return r.status_code == 200, r.content
def _storage_headers(self, headers: Dict[str, str], sc: int) -> None:
if sc == 2:
headers["X-Upload-Storage-Class"] = "archive"
elif sc == 3:
headers["X-Upload-Storage-Class"] = "ia"
# -- direct upload (small files) ----------------------------------------
def direct_upload(self, host: str, oid: str, auth: str, path: str, sc: int) -> None:
def _once():
with open(path, "rb") as f:
data = f.read()
crc = "%08x" % (crc32(data) & 0xFFFFFFFF)
url = f"https://{host}/{oid}"
hdrs: Dict[str, str] = {"Content-CRC32": crc, "Authorization": auth}
self._storage_headers(hdrs, sc)
ok, body = self._put_file(url, path, hdrs)
text = body.decode()
if not ok:
raise RuntimeError(f"direct upload error: {text}, logid: {hdrs.get('X-Tt-Logid', '')}")
j = json.loads(text)
if j.get("success") not in (0,):
raise RuntimeError(f"direct upload error: {text}, logid: {hdrs.get('X-Tt-Logid', '')}")
_retry(_once)
# -- chunked upload (large files) ---------------------------------------
def _init_part(self, host: str, oid: str, auth: str, large: bool, sc: int) -> str:
def _once():
url = f"https://{host}/{oid}?uploads"
hdrs: Dict[str, str] = {"Authorization": auth}
if large:
hdrs["X-Storage-Mode"] = "gateway"
self._storage_headers(hdrs, sc)
ok, body = self._put_data(url, None, hdrs)
text = body.decode()
if not ok:
raise RuntimeError(f"init upload error: {text}")
return json.loads(text)["payload"]["uploadID"]
return _retry(_once)
def _upload_part(self, host: str, oid: str, auth: str, uid: str,
pn: int, data: bytes, large: bool, sc: int) -> Tuple[str, Any]:
def _once():
url = f"https://{host}/{oid}?partNumber={pn}&uploadID={uid}"
crc = "%08x" % (crc32(data) & 0xFFFFFFFF)
hdrs: Dict[str, str] = {"Content-CRC32": crc, "Authorization": auth}
if large:
hdrs["X-Storage-Mode"] = "gateway"
self._storage_headers(hdrs, sc)
ok, body = self._put_data(url, data, hdrs)
text = body.decode()
if not ok:
raise RuntimeError(f"upload part error: {text}")
j = json.loads(text)
if j.get("success") not in (0,):
raise RuntimeError(f"upload part error: {text}")
return crc, j["payload"]
return _retry(_once)
def _merge_parts(self, host: str, oid: str, auth: str, uid: str,
crcs: List[str], large: bool, sc: int, meta: Optional[Dict]) -> None:
def _once():
occ = ""
if meta and meta.get("ObjectContentType"):
occ = meta["ObjectContentType"]
url = f"https://{host}/{oid}?uploadID={uid}&ObjectContentType={occ}"
merge = ",".join(f"{i}:{crcs[i]}" for i in range(len(crcs)))
hdrs: Dict[str, str] = {"Authorization": auth}
if large:
hdrs["X-Storage-Mode"] = "gateway"
self._storage_headers(hdrs, sc)
ok, body = self._put_data(url, merge.encode(), hdrs)
text = body.decode()
if not ok:
raise RuntimeError(f"merge upload error: {text}")
j = json.loads(text)
if j.get("success") not in (0,):
raise RuntimeError(f"merge upload error: {text}")
_retry(_once)
def chunk_upload(self, path: str, host: str, oid: str, auth: str,
size: int, large: bool, sc: int, chunk: int) -> None:
uid = self._init_part(host, oid, auth, large, sc)
n = size // chunk
last = n - 1
crcs: List[str] = []
meta: Dict[str, Any] = {}
with open(path, "rb") as f:
for i in range(last):
pn = i + 1 if large else i
c, payload = self._upload_part(host, oid, auth, uid, pn, f.read(chunk), large, sc)
if pn == 1:
meta = payload.get("meta") or {}
crcs.append(c)
if large:
last = last + 1
c, payload = self._upload_part(host, oid, auth, uid, last, f.read(), large, sc)
if last == 1:
meta = payload.get("meta") or {}
crcs.append(c)
self._merge_parts(host, oid, auth, uid, crcs, large, sc, meta)
# -- VPC pre-signed paths -----------------------------------------------
def vpc_upload(self, addr: Dict[str, Any], path: str, size: int) -> None:
if addr.get("QuickCompleteMode") == "enable":
return
mode = addr.get("UploadMode") or ""
if mode == "direct":
put_url = addr.get("PutUrl") or ""
put_hdrs = _norm_headers(addr.get("PutUrlHeaders"))
with open(path, "rb") as f:
r = self._s.put(put_url, headers=put_hdrs, data=f)
if r.status_code != 200:
raise RuntimeError(f"vpc put error, logId: {r.headers.get('x-tos-request-id', '')}")
elif mode == "part":
self._vpc_part_upload(addr.get("PartUploadInfo") or {}, path, size)
def _vpc_part_upload(self, info: Dict[str, Any], path: str, size: int) -> None:
chunk = int(info.get("PartSize") or 0)
urls = info.get("PartPutUrls") or []
total = size // chunk
if size % chunk == 0:
total -= 1
if len(urls) != total + 1:
raise RuntimeError("mismatch part upload")
offset = 0
etags: List[str] = []
with open(path, "rb") as f:
for i in range(total):
sr = _FileSectionReader(f, chunk, init_offset=offset)
r = self._s.put(urls[i], data=sr)
if r.status_code != 200:
raise RuntimeError(f"vpc part put error, logId: {r.headers.get('x-tos-request-id', '')}")
etags.append(r.headers.get("ETag", ""))
offset += chunk
sr = _FileSectionReader(f, size - offset, init_offset=offset)
r = self._s.put(urls[total], data=sr)
if r.status_code != 200:
raise RuntimeError(f"vpc part put error, logId: {r.headers.get('x-tos-request-id', '')}")
etags.append(r.headers.get("ETag", ""))
parts = ",".join(
'{' + f'"PartNumber": {i + 1}, "ETag": {etags[i]}' + '}'
for i in range(len(etags))
)
body = f'{{"Parts":[{parts}]}}'.encode()
comp_url = info.get("CompletePartUrl") or ""
comp_hdrs = _norm_headers(info.get("CompleteUrlHeaders"))
r = self._s.post(comp_url, data=body, headers=comp_hdrs)
if r.status_code != 200:
raise RuntimeError(f"vpc post error, logId: {r.headers.get('x-tos-request-id', '')}")
# ---------------------------------------------------------------------------
# High-level: pick the best upload path from ApplyUploadInfo data
# ---------------------------------------------------------------------------
def upload_to_tos(
data: Dict[str, Any],
file_path: str,
storage_class: int = 1,
chunk_size: int = 0,
log_fn: Callable[[str], None] = print,
) -> str:
"""
Given the Result.Data from ApplyUploadInfo, upload *file_path* to TOS and
return the SessionKey needed for CommitUploadInfo.
"""
if not os.path.isfile(file_path):
raise RuntimeError(f"file not found: {file_path}")
if chunk_size < MIN_CHUNK_SIZE:
chunk_size = MIN_CHUNK_SIZE
fsize = os.path.getsize(file_path)
uploader = TosUploader()
# 1) VPC pre-signed
vpc = data.get("VpcTosUploadAddress")
if vpc and (vpc.get("UploadMode") or ""):
sk = (data.get("UploadAddress") or {}).get("SessionKey") or ""
uploader.vpc_upload(vpc, file_path, fsize)
return sk
# 2) Candidate addresses (main → backup → fallback)
cand = data.get("CandidateUploadAddresses")
addrs: List[Dict[str, Any]] = []
if cand:
addrs.extend(cand.get("MainUploadAddresses") or [])
addrs.extend(cand.get("BackupUploadAddresses") or [])
addrs.extend(cand.get("FallbackUploadAddresses") or [])
if addrs:
for addr in addrs:
hosts = addr.get("UploadHosts") or []
stores = addr.get("StoreInfos") or []
if not hosts or not stores or not stores[0]:
continue
host = hosts[0]
sk = addr.get("SessionKey") or ""
auth = stores[0].get("Auth") or ""
oid = stores[0].get("StoreUri") or ""
try:
if fsize < chunk_size:
uploader.direct_upload(host, oid, auth, file_path, storage_class)
else:
uploader.chunk_upload(file_path, host, oid, auth, fsize, True, storage_class, chunk_size)
except Exception as exc:
log_fn(f"upload failed on {host}, switching host… ({exc})")
continue
return sk
raise RuntimeError("upload failed on all candidate hosts")
# 3) Default UploadAddress
ua = data.get("UploadAddress") or {}
stores = ua.get("StoreInfos") or []
hosts = ua.get("UploadHosts") or []
if not stores or not hosts:
raise RuntimeError("ApplyUploadInfo: UploadAddress missing StoreInfos or UploadHosts")
oid = stores[0].get("StoreUri") or ""
sk = ua.get("SessionKey") or ""
auth = stores[0].get("Auth") or ""
host = hosts[0]
if fsize < chunk_size:
uploader.direct_upload(host, oid, auth, file_path, storage_class)
else:
uploader.chunk_upload(file_path, host, oid, auth, fsize, True, storage_class, chunk_size)
return sk
#!/usr/bin/env python3
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
upload.py — upload a media asset to BytePlus VOD and return the Vid
Supported inputs:
1) Local file path → ApplyUploadInfo + TOS direct/chunked PUT + CommitUploadInfo
2) http/https link → UploadMediaByUrl (async) + poll QueryUploadTaskInfo
Usage:
uv run python scripts/upload.py "<local_path_or_url>" [space_name]
Output (JSON on stdout):
{"Vid":"vxxxx","Source":"vid://vxxxx","PlayURL":"...","PosterUri":"","FileName":"...","SpaceName":"...","SourceUrl":"..."}
PlayURL is built via get_play_url_by_filename (storage host / play domain + path).
"""
import os
import sys
import time
import uuid
from pathlib import Path
from urllib.parse import urlparse
sys.path.insert(0, str(Path(__file__).resolve().parent))
from vod_client import (
get_client,
get_space_name,
get_play_url_by_filename,
apply_upload_info,
commit_upload_info,
out,
log,
bail,
)
from tos_upload import upload_to_tos
# ── Polling configuration ──────────────────────────────────────────────────
POLL_INTERVAL = float(os.environ.get("VOD_POLL_INTERVAL", "5"))
POLL_MAX = int(os.environ.get("VOD_POLL_MAX", "360")) # 360 × 5s = 30 minutes
# ── VOD API constants ──────────────────────────────────────────────────────
_ACTION_UPLOAD_BY_URL = "UploadMediaByUrl"
_ACTION_QUERY_TASK = "QueryUploadTaskInfo"
_VERSION = "2023-01-01"
# ══════════════════════════════════════════════════════════════════════════
# Path safety: restrict which local paths may be uploaded
# ══════════════════════════════════════════════════════════════════════════
_ALLOWED_PREFIXES: list[str] | None = None
def _get_allowed_prefixes() -> list[str]:
global _ALLOWED_PREFIXES
if _ALLOWED_PREFIXES is not None:
return _ALLOWED_PREFIXES
prefixes: list[str] = []
# 1) WORKSPACE (or cwd) — the project the agent is operating in
ws = os.environ.get("WORKSPACE", os.getcwd())
prefixes.append(os.path.realpath(ws))
# 2) sibling userdata/ directory (Cursor sandbox convention)
ud = os.path.join(os.path.dirname(ws), "userdata") if ws else ""
if ud:
prefixes.append(os.path.realpath(ud))
# 3) /tmp — commonly used for scratch files
prefixes.append("/tmp")
# 4) VOD_UPLOAD_ALLOWED_DIRS — comma-separated extra directories
extra = os.environ.get("VOD_UPLOAD_ALLOWED_DIRS", "")
for d in extra.split(","):
d = d.strip()
if d:
prefixes.append(os.path.realpath(d))
_ALLOWED_PREFIXES = prefixes
return _ALLOWED_PREFIXES
def _validate_local_path(file_path: str) -> None:
"""Ensure the resolved path falls under an allowed prefix (symlink-safe)."""
real = os.path.realpath(file_path)
for prefix in _get_allowed_prefixes():
if real == prefix or real.startswith(prefix + os.sep):
return
bail(
f"Path not allowed. Only files under workspace/, userdata/, /tmp, "
f"or VOD_UPLOAD_ALLOWED_DIRS may be uploaded. Rejected: {file_path}"
)
def _guess_ext(path_str: str) -> str:
_, ext = os.path.splitext(path_str or "")
ext = ext.strip()
if not ext:
bail("The file must carry a file extension (e.g. .mp4 / .mov / .mp3)")
if not ext.startswith("."):
ext = "." + ext
return ext
def _is_url(s: str) -> bool:
return s.startswith("http://") or s.startswith("https://")
# ══════════════════════════════════════════════════════════════════════════
# URL upload (async pull)
# ══════════════════════════════════════════════════════════════════════════
def _submit_url_upload(client, space_name: str, source_url: str, file_ext: str) -> list:
file_name = f"{uuid.uuid4().hex}{file_ext}"
body = {
"SpaceName": space_name,
"URLSets": [
{
"SourceUrl": source_url,
"FileExtension": file_ext,
"FileName": file_name,
}
],
}
log(f"Submitting URL upload: {source_url}")
resp = client.post(_ACTION_UPLOAD_BY_URL, _VERSION, body)
result = resp.get("Result", {}) or {}
data = result.get("Data", []) or []
job_ids = [item["JobId"] for item in data if isinstance(item, dict) and item.get("JobId")]
if not job_ids:
bail(f"UploadMediaByUrl did not return a JobId, response: {resp}")
return job_ids
def _poll_upload_task(client, job_ids_str: str) -> dict:
last_state = ""
for i in range(1, POLL_MAX + 1):
log(f"Polling upload task [{i}/{POLL_MAX}] JobIds={job_ids_str}")
try:
resp = client.get(_ACTION_QUERY_TASK, _VERSION, {"JobIds": job_ids_str})
except Exception as exc:
log(f" query exception: {exc}")
time.sleep(POLL_INTERVAL)
continue
result = resp.get("Result", {}) or {}
data = result.get("Data", {}) or {}
media_list = data.get("MediaInfoList", []) or []
if not media_list:
time.sleep(POLL_INTERVAL)
continue
item = media_list[0]
state = item.get("State", "")
last_state = state or last_state
vid = item.get("Vid", "")
if vid:
source_info = item.get("SourceInfo", {}) or {}
return {
"Vid": vid,
"FileName": source_info.get("FileName", ""),
"State": state,
"SpaceName": item.get("SpaceName", ""),
"JobId": item.get("JobId", ""),
}
if state.lower() in {"fail", "failed", "error"}:
bail(f"URL pull upload failed: State={state!r}, JobIds={job_ids_str}")
time.sleep(POLL_INTERVAL)
return {
"error": f"Polling timed out ({POLL_MAX} attempts × {POLL_INTERVAL}s); the URL pull upload is still processing",
"resume_hint": {
"description": "The URL upload has not finished yet; retry with the command below",
"command": 'uv run python scripts/upload.py "<original URL>" [space_name]',
},
"JobIds": job_ids_str,
"State": last_state,
}
def _do_url_upload(client, space_name: str, source_url: str) -> None:
file_ext = _guess_ext(urlparse(source_url).path)
try:
job_ids = _submit_url_upload(client, space_name, source_url, file_ext)
except SystemExit:
raise
except Exception as exc:
bail(f"Failed to submit URL upload: {exc}")
job_ids_str = ",".join(job_ids)
log(f"Upload job submitted, JobIds={job_ids_str}")
info = _poll_upload_task(client, job_ids_str)
if "error" in info:
out(info)
return
vid = info.get("Vid", "")
file_name = (info.get("FileName") or "").strip()
play_url = _build_play_url(client, space_name, file_name, vid)
out({
"Vid": vid,
"Source": f"vid://{vid}",
"PlayURL": play_url,
"PosterUri": "",
"FileName": file_name,
"SpaceName": space_name,
"SourceUrl": source_url,
"JobId": job_ids_str,
})
# ══════════════════════════════════════════════════════════════════════════
# Local file upload (ApplyUploadInfo → TOS → CommitUploadInfo)
# ══════════════════════════════════════════════════════════════════════════
def _do_local_upload(client, space_name: str, file_path: str) -> None:
_validate_local_path(file_path)
if not os.path.isfile(file_path):
bail(f"Local file not found: {file_path}")
file_ext = _guess_ext(file_path)
file_size = os.path.getsize(file_path)
file_name = f"{uuid.uuid4().hex}{file_ext}"
log(f"Local upload: {file_path} ({file_size} bytes) → FileName={file_name}")
# Step 1: ApplyUploadInfo
try:
apply_data = apply_upload_info(
client, space_name,
file_size=file_size, file_name=file_name, file_ext=file_ext,
)
except SystemExit:
raise
except Exception as exc:
bail(f"ApplyUploadInfo failed: {exc}")
# Step 2: upload binary to TOS
try:
session_key = upload_to_tos(apply_data, file_path, log_fn=log)
except SystemExit:
raise
except Exception as exc:
bail(f"TOS upload failed: {exc}")
# Step 3: CommitUploadInfo
try:
commit_data = commit_upload_info(client, space_name, session_key)
except SystemExit:
raise
except Exception as exc:
bail(f"CommitUploadInfo failed: {exc}")
vid = commit_data.get("Vid", "")
if not vid:
bail("CommitUploadInfo succeeded but returned no Vid")
source_info = commit_data.get("SourceInfo") or {}
returned_file_name = source_info.get("FileName", "")
play_url = _build_play_url(client, space_name, returned_file_name, vid)
out({
"Vid": vid,
"Source": f"vid://{vid}",
"PlayURL": play_url,
"PosterUri": commit_data.get("PosterUri", ""),
"FileName": returned_file_name,
"SpaceName": space_name,
"SourceUrl": file_path,
})
# ══════════════════════════════════════════════════════════════════════════
# Shared: build a play URL from FileName (fallback to Vid as path key)
# ══════════════════════════════════════════════════════════════════════════
def _build_play_url(client, space_name: str, file_name: str, vid: str) -> str:
vid_key = vid[len("vid://"):] if vid.startswith("vid://") else vid
path_for_url = (file_name or vid_key).strip()
if not path_for_url:
return ""
expired = int(os.environ.get("VOD_URL_EXPIRE_MINUTES", "60"))
if not file_name and vid_key:
log("No FileName; trying get_play_url_by_filename with Vid as path key")
return get_play_url_by_filename(client, space_name, path_for_url, expired_minutes=expired)
# ══════════════════════════════════════════════════════════════════════════
# main
# ══════════════════════════════════════════════════════════════════════════
def main():
if len(sys.argv) < 2:
bail('Usage: uv run python scripts/upload.py "<local_path_or_url>" [space_name]')
source = sys.argv[1].strip()
space_name = get_space_name(argv_pos=2)
client = get_client()
if _is_url(source):
_do_url_upload(client, space_name, source)
else:
_do_local_upload(client, space_name, source)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
vod_client.py — shared VOD transport layer and utility helpers
Authentication:
- Direct OpenAPI HMAC-SHA256
- Preferred env: BYTEPLUS_ACCESSKEY + BYTEPLUS_SECRETKEY
- Backward-compatible env: VOLCENGINE_ACCESS_KEY + VOLCENGINE_SECRET_KEY
"""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import secrets
import sys
import time
from datetime import datetime, timedelta, timezone
from functools import reduce
from pathlib import Path
from typing import NoReturn
from urllib.parse import quote, urlencode
import requests
from dotenv import load_dotenv
# ── .env loading ───────────────────────────────────────────────────────────
# Look for .env in the current working directory first, then in the script's
# own directory (so the skill still works when invoked from elsewhere).
_SCRIPT_DIR = Path(__file__).resolve().parent
for _base in (Path.cwd(), _SCRIPT_DIR):
_env = _base / ".env"
if _env.is_file():
load_dotenv(_env, override=False)
break
# ── VOD host ───────────────────────────────────────────────────────────────
# Defaults to the BytePlus (overseas) endpoint. Set VOD_HOST to override,
# e.g. "vod.volcengineapi.com" for the Volcengine (mainland China) endpoint.
_VOD_HOST = (os.environ.get("VOD_HOST") or "vod.byteplusapi.com").strip()
_HTTP_TIMEOUT = float(os.environ.get("VOD_HTTP_TIMEOUT", "20"))
# ══════════════════════════════════════════════════════════════════════════
# Output helpers
# ══════════════════════════════════════════════════════════════════════════
def out(data: dict):
"""Print the result as JSON to stdout."""
print(json.dumps(data, ensure_ascii=False), flush=True)
def log(msg: str):
"""Write a debug message to stderr."""
print(f"[byted-byteplus-vod-video-enhancement] {msg}", file=sys.stderr, flush=True)
def bail(msg: str) -> NoReturn:
"""Print an error JSON and exit."""
out({"error": msg})
sys.exit(1)
# ══════════════════════════════════════════════════════════════════════════
# Volcengine OpenAPI client (HMAC-SHA256 signing)
# ══════════════════════════════════════════════════════════════════════════
class VolcClient:
_REGION = (os.environ.get("VOD_REGION") or "ap-southeast-1").strip()
_SERVICE = "vod"
def __init__(self, ak: str, sk: str):
self._ak = ak
self._sk = sk
def post(self, action: str, version: str, body: dict) -> dict:
body_str = json.dumps(body, ensure_ascii=False)
url, headers = self._sign("POST", action, version, {}, body_str)
r = requests.post(url, headers=headers, data=body_str.encode(), timeout=_HTTP_TIMEOUT)
self._check(r)
return r.json()
def get(self, action: str, version: str, params: dict) -> dict:
url, headers = self._sign("GET", action, version, params or {}, "")
r = requests.get(url, headers=headers, timeout=_HTTP_TIMEOUT)
self._check(r)
return r.json()
def _sign(self, method: str, action: str, version: str,
query_extra: dict, body_str: str) -> tuple[str, dict]:
qp = {"Action": action, "Version": version}
qp.update(query_extra)
canonical_query = urlencode(sorted(qp.items()), quote_via=quote, safe="-_.~")
url = f"https://{_VOD_HOST}/?{canonical_query}"
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
body_hash = hashlib.sha256(body_str.encode()).hexdigest()
h = {
"content-type": "application/json; charset=utf-8",
"host": _VOD_HOST,
"x-content-sha256": body_hash,
"x-date": ts,
}
signed_keys = sorted(h.keys())
canonical_headers = "".join(f"{k}:{h[k]}\n" for k in signed_keys)
signed_headers_str = ";".join(signed_keys)
canonical_request = (
f"{method}\n/\n{canonical_query}\n"
f"{canonical_headers}\n{signed_headers_str}\n{body_hash}"
)
credential_scope = f"{ts[:8]}/{self._REGION}/{self._SERVICE}/request"
string_to_sign = (
f"HMAC-SHA256\n{ts}\n{credential_scope}\n"
f"{hashlib.sha256(canonical_request.encode()).hexdigest()}"
)
signing_key = reduce(
lambda k, v: hmac.new(k, v.encode(), hashlib.sha256).digest(),
[ts[:8], self._REGION, self._SERVICE, "request"],
self._sk.encode(),
)
signature = hmac.new(signing_key, string_to_sign.encode(),
hashlib.sha256).hexdigest()
headers = {k.title().replace("X-C", "X-c"): v for k, v in h.items()}
headers["Authorization"] = (
f"HMAC-SHA256 Credential={self._ak}/{credential_scope}, "
f"SignedHeaders={signed_headers_str}, Signature={signature}"
)
return url, headers
@staticmethod
def _check(r):
if r.status_code != 200:
raise RuntimeError(f"HTTP {r.status_code}: {r.text}")
# ══════════════════════════════════════════════════════════════════════════
# Factory: pick a client based on environment variables
# ══════════════════════════════════════════════════════════════════════════
def get_client() -> VolcClient:
"""Build a direct OpenAPI client from environment variables."""
ak = (os.environ.get("BYTEPLUS_ACCESSKEY") or "").strip() or (os.environ.get("VOLCENGINE_ACCESS_KEY") or "").strip()
sk = (os.environ.get("BYTEPLUS_SECRETKEY") or "").strip() or (os.environ.get("VOLCENGINE_SECRET_KEY") or "").strip()
if not (ak and sk):
bail(
"Missing credentials. Set BYTEPLUS_ACCESSKEY and BYTEPLUS_SECRETKEY "
"(preferred) or VOLCENGINE_ACCESS_KEY and VOLCENGINE_SECRET_KEY "
"(legacy) in the environment or in .env."
)
return VolcClient(ak, sk)
# ══════════════════════════════════════════════════════════════════════════
# Space name resolution
# ══════════════════════════════════════════════════════════════════════════
def get_space_name(argv_pos: int = 2) -> str:
"""Resolve space_name with priority: CLI argument > VOD_SPACE_NAME env."""
if len(sys.argv) > argv_pos:
v = sys.argv[argv_pos].strip()
if v:
return v
sp = (os.environ.get("VOD_SPACE_NAME") or "").strip()
if sp:
return sp
bail("VOD space name not specified: pass it as a CLI argument or set VOD_SPACE_NAME.")
# ══════════════════════════════════════════════════════════════════════════
# Media input builder (used by StartExecution)
# ══════════════════════════════════════════════════════════════════════════
def build_media_input(asset_type: str, asset_value: str, space_name: str) -> dict:
"""
Build the Input field for StartExecution.
asset_type: "Vid" or "DirectUrl"
asset_value: the vid value (without the vid:// prefix) or the VOD FileName
"""
if asset_type not in ("Vid", "DirectUrl"):
bail(f"type must be Vid or DirectUrl, got: {asset_type!r}")
if not asset_value:
bail("media asset value must not be empty")
if not space_name:
bail("space_name must not be empty")
# Strip protocol prefix
value = asset_value
if value.startswith("vid://"):
value = value[len("vid://"):]
elif value.startswith("directurl://"):
value = value[len("directurl://"):]
media_input: dict = {"Type": asset_type}
if asset_type == "Vid":
media_input["Vid"] = value
else:
media_input["DirectUrl"] = {"FileName": value, "SpaceName": space_name}
return media_input
# ══════════════════════════════════════════════════════════════════════════
# Playback URL signing (turn a DirectUrl/FileName into an accessible URL)
# Reference: vod-media-kit/volcengine-ai-mediakit/scripts/api_manage.py
# ══════════════════════════════════════════════════════════════════════════
_VOD_ACTION_APPLY_UPLOAD_INFO = "ApplyUploadInfo"
_VOD_ACTION_COMMIT_UPLOAD_INFO = "CommitUploadInfo"
_VOD_ACTION_LIST_DOMAIN = "ListDomain"
_VOD_ACTION_DESCRIBE_DOMAIN_CONFIG = "DescribeDomainConfig"
_VOD_ACTION_GET_STORAGE_CONFIG = "GetStorageConfig"
_VOD_ACTION_GET_PLAY_INFO = "GetPlayInfo"
_VOD_ACTION_UPDATE_MEDIA_PUBLISH_STATUS = "UpdateMediaPublishStatus"
_VOD_VERSION = "2023-01-01"
_CACHE: dict = {"available_domains": {}, "storage_config": {}}
def _encode_path_str(s: str = "") -> str:
return quote(s, safe="-_.~$&+,/:;=@")
def _encode_rfc3986_uri_component(s: str) -> str:
return quote(s, safe=":/?&=%-_.~")
def _random_string(length: int) -> str:
alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
return "".join(secrets.choice(alphabet) for _ in range(length))
def _parse_time(value):
if isinstance(value, (int, float)):
try:
return datetime.fromtimestamp(float(value), tz=timezone.utc)
except Exception:
return None
if isinstance(value, str):
try:
v = value.replace("Z", "+00:00") if "Z" in value else value
return datetime.fromisoformat(v)
except Exception:
return None
return None
def _is_https_available(certificate: dict) -> bool:
if certificate and certificate.get("HttpsStatus") == "enable":
exp = _parse_time(certificate.get("ExpiredAt"))
if exp:
return exp > datetime.now(timezone.utc)
return False
def _get_domain_config(client: VolcClient, domain: str, space_name: str, domain_type: str = "play") -> dict:
detail = client.get(
_VOD_ACTION_DESCRIBE_DOMAIN_CONFIG,
_VOD_VERSION,
{"SpaceName": space_name, "Domain": domain, "DomainType": domain_type},
)
result = detail.get("Result", {}) if isinstance(detail, dict) else {}
cdn_config = result.get("Config") or {}
signed_url_auth_control = cdn_config.get("SignedUrlAuthControl") or {}
signed_url_auth_rules = (signed_url_auth_control.get("SignedUrlAuth") or {}).get("SignedUrlAuthRules", [])
if not signed_url_auth_rules:
return {}
signed_url_auth_action = (signed_url_auth_rules[0] or {}).get("SignedUrlAuthAction", {}) or {}
base_domain = result.get("Domain", {}) or {}
status = "enable" if base_domain.get("ConfigStatus") == "online" else base_domain.get("ConfigStatus")
return {
"AuthType": signed_url_auth_action.get("URLAuthType"),
"AuthKey": signed_url_auth_action.get("MasterSecretKey")
or signed_url_auth_action.get("BackupSecretKey")
or "",
"Status": status,
"Domain": base_domain.get("Domain", ""),
}
def _get_available_domain(client: VolcClient, space_name: str) -> list[dict]:
cached = (_CACHE.get("available_domains") or {}).get(space_name) or []
if cached:
return cached
offset = 0
total = 1
domain_list: list[dict] = []
while offset < total:
data = client.get(
_VOD_ACTION_LIST_DOMAIN,
_VOD_VERSION,
{"SpaceName": space_name, "SourceStationType": 1, "DomainType": "play", "Offset": offset},
)
offset = int(data.get("Offset", 0) or 0)
total = int(data.get("Total", 0) or 0)
result = data.get("Result", {}) or {}
instances = ((result.get("PlayInstanceInfo") or {}).get("ByteInstances") or [])
for item in instances:
domains = item.get("Domains") or []
for domain in domains:
d = dict(domain)
d["SourceStationType"] = 1
d["DomainType"] = "play"
domain_list.append(d)
domain_list = [d for d in domain_list if d.get("CdnStatus") == "enable"]
enriched: list[dict] = []
for d in domain_list:
auth_info = _get_domain_config(client, d.get("Domain", ""), space_name, d.get("DomainType", "play"))
d2 = dict(d)
d2["AuthInfo"] = auth_info
enriched.append(d2)
available = [d for d in enriched if (not d.get("AuthInfo")) or ((d.get("AuthInfo") or {}).get("AuthType") == "typea")]
_CACHE["available_domains"] = {**(_CACHE.get("available_domains") or {}), space_name: available}
return available
def _gen_url(domain_obj: dict, path: str, expired_minutes: int) -> str:
is_https = _is_https_available(domain_obj.get("Certificate") or {})
file_name = f"/{path}"
auth_info = domain_obj.get("AuthInfo") or {}
if auth_info.get("AuthType") == "typea":
expire_ts = int((datetime.now(timezone.utc) + timedelta(minutes=expired_minutes)).timestamp())
rand_str = _random_string(16)
key = auth_info.get("AuthKey") or ""
md5_input = f"{_encode_path_str(file_name)}-{expire_ts}-{rand_str}-0-{key}".encode("utf-8")
md5_str = hashlib.md5(md5_input).hexdigest()
url = (
f"{'https' if is_https else 'http'}://{domain_obj.get('Domain')}{file_name}"
f"?auth_key={expire_ts}-{rand_str}-0-{md5_str}"
)
return _encode_rfc3986_uri_component(url)
url = f"{'https' if is_https else 'http'}://{domain_obj.get('Domain')}{file_name}"
return _encode_rfc3986_uri_component(url)
def _get_storage_config(client: VolcClient, space_name: str) -> dict:
cached = (_CACHE.get("storage_config") or {}).get(space_name) or {}
if cached:
return cached
reqs = client.get(_VOD_ACTION_GET_STORAGE_CONFIG, _VOD_VERSION, {"SpaceName": space_name})
storage_config = reqs.get("Result") or {}
_CACHE["storage_config"] = {**(_CACHE.get("storage_config") or {}), space_name: storage_config}
return storage_config
def _gen_wild_url(storage_config: dict, file_name: str) -> str:
file_path = f"/{file_name}"
conf = storage_config.get("StorageUrlAuthConfig") or {}
if (
storage_config.get("StorageType") == "volc"
and conf.get("Type") == "cdn_typea"
and conf.get("Status") == "enable"
):
type_a = conf.get("TypeAConfig") or {}
expire_seconds = int(type_a.get("ExpireTime") or 0)
expire_ts = int((datetime.now(timezone.utc) + timedelta(seconds=expire_seconds)).timestamp())
rand_str = _random_string(16)
key = type_a.get("MasterKey") or type_a.get("BackupKey") or ""
md5_input = f"{_encode_path_str(file_path)}-{expire_ts}-{rand_str}-0-{key}".encode("utf-8")
md5_str = hashlib.md5(md5_input).hexdigest()
sig_arg = type_a.get("SignatureArgs") or "auth_key"
signed = f"{storage_config.get('StorageHost')}{file_path}?{sig_arg}={expire_ts}-{rand_str}-0-{md5_str}&preview=1"
return _encode_rfc3986_uri_component(signed)
if storage_config.get("StorageType") == "volc" and conf.get("Status") == "disable":
signed = f"{storage_config.get('StorageHost')}{file_path}?preview=1"
return _encode_rfc3986_uri_component(signed)
return ""
def get_play_url_by_filename(
client: VolcClient,
space_name: str,
file_name: str,
*,
expired_minutes: int = 60,
) -> str:
"""
Turn a DirectUrl/FileName into an accessible URL (possibly carrying typea auth params).
Priority:
1) env `VOD_PLAY_DOMAIN` (build the URL directly from this domain; use to force a specific domain)
2) ListDomain + DescribeDomainConfig (obtain the CDN domain and auth rules)
3) GetStorageConfig fallback (use StorageHost + optional cdn_typea)
"""
if not file_name:
return ""
cleaned = file_name
if cleaned.startswith("directurl://"):
cleaned = cleaned[len("directurl://"):]
forced_domain = (os.environ.get("VOD_PLAY_DOMAIN") or "").strip()
if forced_domain:
domain = forced_domain.rstrip("/")
scheme = "https" if "://" not in domain else ""
if scheme:
domain = f"https://{domain}"
return _encode_rfc3986_uri_component(f"{domain}/{cleaned.lstrip('/')}")
try:
available = _get_available_domain(client, space_name)
if available:
return _gen_url(available[0], cleaned.lstrip("/"), expired_minutes)
except Exception as exc:
log(f"Failed to fetch play domain; falling back to storage: {exc}")
try:
storage_config = _get_storage_config(client, space_name)
return _gen_wild_url(storage_config, cleaned.lstrip("/"))
except Exception as exc:
log(f"Storage fallback signing failed: {exc}")
return ""
# ══════════════════════════════════════════════════════════════════════════
# Local file upload: ApplyUploadInfo → TOS PUT → CommitUploadInfo
# Docs: https://docs.byteplus.com/en/byteplus-vod/reference/applyuploadinfo
# https://docs.byteplus.com/en/byteplus-vod/reference/commituploadinfo
# ══════════════════════════════════════════════════════════════════════════
def apply_upload_info(client: VolcClient, space_name: str, *,
file_size: int, file_name: str, file_ext: str) -> dict:
"""
Call ApplyUploadInfo and return Result.Data (contains UploadAddress, SessionKey, etc.).
"""
resp = client.get(
_VOD_ACTION_APPLY_UPLOAD_INFO,
_VOD_VERSION,
{
"SpaceName": space_name,
"FileSize": file_size,
"FileType": "",
"FileName": file_name,
"FileExtension": file_ext,
"StorageClass": 1,
"NeedFallback": True,
},
)
_raise_for_vod(resp)
return ((resp.get("Result") or {}).get("Data")) or {}
def commit_upload_info(client: VolcClient, space_name: str, session_key: str) -> dict:
"""
Call CommitUploadInfo and return Result.Data (contains Vid, SourceInfo, etc.).
"""
resp = client.get(
_VOD_ACTION_COMMIT_UPLOAD_INFO,
_VOD_VERSION,
{"SpaceName": space_name, "SessionKey": session_key},
)
_raise_for_vod(resp)
return ((resp.get("Result") or {}).get("Data")) or {}
def _raise_for_vod(resp: dict) -> None:
meta = resp.get("ResponseMetadata") or {}
err = meta.get("Error") or {}
code = err.get("Code", "") or err.get("code", "")
if code not in ("", None, 0, "0"):
rid = meta.get("RequestId", "")
raise RuntimeError(f"VOD API error: {err} request_id={rid}")
# ══════════════════════════════════════════════════════════════════════════
# Publish + GetPlayInfo helpers
# ══════════════════════════════════════════════════════════════════════════
def _update_media_publish_status(client: VolcClient, vid: str, status: str = "Published") -> None:
"""
Publish (or unpublish) a media asset.
Docs: https://docs.byteplus.com/en/docs/byteplus-vod/reference-updatemediapublishstatus
Required before GetPlayInfo will return a playable URL.
"""
client.get(
_VOD_ACTION_UPDATE_MEDIA_PUBLISH_STATUS,
_VOD_VERSION,
{"Vid": vid, "Status": status},
)
def _get_play_info(client: VolcClient, vid: str) -> str:
"""
Call GetPlayInfo once and return the first playable URL, or "" if none.
Docs: https://docs.byteplus.com/en/docs/byteplus-vod/reference-getplayinfo
Response shape: Result.PlayInfoList[*].{MainPlayUrl, BackupPlayUrl}.
Ssl=1 requests HTTPS playback URLs.
"""
resp = client.get(
_VOD_ACTION_GET_PLAY_INFO,
_VOD_VERSION,
{"Vid": vid, "Ssl": "1"},
)
result = resp.get("Result", {}) if isinstance(resp, dict) else {}
play_list = result.get("PlayInfoList") or []
for entry in play_list:
if not isinstance(entry, dict):
continue
url = entry.get("MainPlayUrl") or entry.get("BackupPlayUrl") or ""
if url:
return url
return ""
def get_play_url_by_vid(client: VolcClient, vid: str) -> str:
"""
Fetch a playable URL by Vid.
Per the BytePlus docs, GetPlayInfo will only return URLs after the asset
has been published. UpdateMediaPublishStatus is idempotent, so we always
publish first and then query once:
1. UpdateMediaPublishStatus(Published)
2. GetPlayInfo
"""
if not vid:
return ""
v = vid[len("vid://"):] if vid.startswith("vid://") else vid
try:
_update_media_publish_status(client, v, "Published")
except Exception as exc:
# Publishing failures are logged but not fatal — we still attempt
# GetPlayInfo in case the asset was already Published.
log(f"UpdateMediaPublishStatus failed (vid={v}): {exc}")
else:
# Small delay to let the publish propagate before querying.
time.sleep(0.35)
try:
return _get_play_info(client, v)
except Exception as exc:
log(f"GetPlayInfo failed (vid={v}): {exc}")
return ""