@vcdn/node
v1.0.3
Published
Node.js SDK for VCDN upload-service public API
Readme
@vcdn/node
Node.js SDK for the VCDN upload-service public API. It supports multipart uploads from local files, Blob/Buffer uploads, video management APIs, playback tokens, HLS directory ingest, and a smart HLS normalize pipeline that validates and optionally remuxes content before upload.
Requires Node.js >=18.
Install
pnpm add @vcdn/nodeCreate a Client
import { VcdnNodeClient } from "@vcdn/node";
const client = new VcdnNodeClient({
apiKey: process.env.VCDN_API_KEY!,
baseUrl: process.env.VCDN_BASE_URL!,
});baseUrl must be the upload-service origin only, for example https://upload.example.com. Do not append /api/v1.
Upload a Local Video File
const result = await client.uploadFileFromPath(
"/tmp/video.mp4",
{
title: "Release demo",
quality: "720p",
ladderProfile: "standard",
},
{
chunkSize: 8 * 1024 * 1024,
},
);
console.log(result.videoId, result.status);uploadFileFromPath and uploadBlob are video ingest helpers.
Upload a Generic File
const asset = await client.uploadGenericFileFromPath("/data/report.pdf", {
init: { contentType: "application/pdf" },
onProgress: ({ loaded, total }) => console.log(`${loaded}/${total}`),
});
const downloadUrl = await client.getFileDownloadUrl(asset.id, { ttlSeconds: 3600 });
console.log(asset.id, asset.status, downloadUrl);Path uploads keep only one client chunk in memory at a time. The default client chunk size is 8 MiB; provider-specific chunking is handled later by backend workers.
const bytes = await fetch("https://example.com/archive.zip").then((res) => res.arrayBuffer());
const fromMemory = await client.uploadGenericFile(bytes, {
filename: "archive.zip",
contentType: "application/zip",
onProgress: ({ loaded, total }) => console.log(`${loaded}/${total}`),
});Set waitUntilReady: false to return after complete; otherwise the SDK polls GET /api/v1/files/{id} until ready and throws if processing fails.
Upload a Video Blob or Buffer
const bytes = await fetch("https://example.com/video.mp4").then((res) => res.arrayBuffer());
await client.uploadBlob(bytes, {
filename: "video.mp4",
size: bytes.byteLength,
contentType: "video/mp4",
title: "Remote import",
});Upload Image
const image = await client.uploadImage(
await fetch("https://example.com/cover.png").then((r) => r.arrayBuffer()),
{
filename: "cover.png",
contentType: "image/png",
waitUntilReady: true, // default true
timeoutMs: 120_000,
pollIntervalMs: 1500,
},
);
console.log(image.id, image.status, image.url);uploadImage(...) runs the full flow:
POST /api/v1/images/upload/initPUT /api/v1/images/{id}/filePOST /api/v1/images/upload/complete- Poll
GET /api/v1/images/{id}untilready | degraded | failed.
For edge delivery (short-lived token URL), call after the image is ready:
const edge = await client.getImageEdgeLink(image.id, { provider: "p1", ttlSeconds: 3600 });
console.log(edge.url); // https://edge.../image/{token}Video APIs and Playback Tokens
const list = await client.listVideos({ page: 1, limit: 20 });
const video = await client.getVideo(list.items[0]!.id);
const playback = await client.createPlaybackToken(video.id, { ttlSeconds: 600 });
console.log(playback.streamUrl);Available video APIs:
listVideos(query?, signal?)getVideo(id, signal?)deleteVideo(id, signal?)createPlaybackToken(videoId, body?, signal?)getPlaybackUrl(videoId, body?, signal?)
HLS Directory Upload
Use VcdnClient when you have one media .m3u8 playlist and .ts segments on disk. Master playlists and multiple .m3u8 trees under one root are not supported.
import { VcdnClient } from "@vcdn/node";
const hls = new VcdnClient({
apiKey: process.env.VCDN_API_KEY!,
baseUrl: process.env.VCDN_BASE_URL!,
});
const result = await hls.uploadHLS({
path: "/path/to/hls-output",
title: "Release demo HLS",
concurrency: 8,
metrics: true,
onProgress: (percent) => console.log(`${percent}%`),
});
console.log(result.video_id, result.upload_id);uploadHLS uploads missing .ts segments, uploads the playlist, completes the video, and waits until the video is ready. Pass title to set the video title shown in dashboards/lists.
Smart HLS Normalize Pipeline
The SDK includes a built-in normalize pipeline that validates HLS/TS integrity and optionally remuxes content before upload. This ensures Safari compatibility and keeps segments within CDN provider size limits — all without re-encoding.
Requirements
- ffmpeg
>=4.4— required only when normalization is triggered (normalize: 'auto'with issues detected, or'force'). Not needed whennormalize: falseor when validation passes in'auto'mode. - ffmpeg/ffprobe are auto-detected from
PATH, or you can provide explicit paths viaffmpegPath/ffprobePath.
Normalize Modes
| Mode | Behavior |
|------|----------|
| false | Upload raw HLS without any validation or remux. |
| 'auto' (default) | Validate HLS + TS. Only normalize/remux if issues detected. |
| 'force' | Always normalize/remux before upload. |
| 'strict' | Validate only. Reject upload if unsafe. Never auto-repair. |
What Validation Checks
- TS integrity: Sync byte (0x47) alignment, packet alignment (divisible by 188 bytes)
- Segment size: Detects segments exceeding
maxSegmentSizeMB(default 5 MB) - Manifest consistency: Segment count vs files on disk, invalid EXTINF durations, sequence ordering
- Safari risk heuristics: Packet misalignment, inconsistent segment durations
How Normalization Works
When normalization is triggered, the pipeline runs a deliberately minimal merge then split with -c copy:
- Probes the input using ffprobe (bitrate, duration, codec info — read-only).
- Merge → MP4:
ffmpeg -y -i input.m3u8 -map 0 -c copy -bsf:a aac_adtstoasc temp.mp4 - Split → HLS:
ffmpeg -y -i temp.mp4 -map 0 -c copy \ -f hls -hls_time <N> -hls_playlist_type vod \ -hls_segment_type mpegts -hls_segment_filename seg%04d.ts \ -hls_list_size 0 \ -mpegts_flags +resend_headers+initial_discontinuity output.m3u8
The hls_time is dynamically calculated from bitrate to keep segments under maxSegmentSizeMB, clamped to 2–6 seconds.
Important: This is a transmux pipeline (-c copy). It does NOT re-encode, change codecs, alter resolution, or degrade quality.
No re-encoding is used. Two container-compliance flags remain enabled because Safari and AAC-in-MP4 require them:
-bsf:a aac_adtstoasc— losslessly converts AAC framing from ADTS (TS) to AudioSpecificConfig (MP4), which is required by the MP4 container spec. Without this, Safari may drop audio after normalization.-mpegts_flags +resend_headers+initial_discontinuity— emits PAT/PMT headers at TS segment boundaries and an initial discontinuity marker so Safari can initialize audio reliably.
The following flags remain intentionally removed/commented out in repos/sdk/node/src/normalize/remux.ts:
-map 0:v? -map 0:a?— replaced with-map 0to preserve every input stream (subtitles, data, secondary audio).-movflags +faststart— would relocate themoovatom in the temporary MP4 (pure layout rewrite).-hls_flags independent_segments— would inject#EXT-X-INDEPENDENT-SEGMENTSand force keyframe-aligned segment cuts.-max_muxing_queue_size 1024— muxer buffer tuning.
Example with Normalize
import { VcdnClient } from "@vcdn/node";
const hls = new VcdnClient({
apiKey: process.env.VCDN_API_KEY!,
baseUrl: process.env.VCDN_BASE_URL!,
debug: true,
});
const out = await hls.uploadHLS({
path: "/path/to/hls-out",
title: "Episode 12 - 720p HLS",
concurrency: 8,
metrics: true,
// Normalize options
normalize: "auto", // 'auto' | 'force' | 'strict' | false
maxSegmentSizeMB: 5, // Max segment size before triggering normalize
ffmpegPath: "/usr/bin/ffmpeg", // Optional: auto-detected from PATH
tempDir: "./tmp", // Optional: defaults to os.tmpdir()
ffmpegTimeoutMs: 300_000, // Optional: 5 min default
onProgress(event) {
if (typeof event === "number") {
// Legacy: upload percent only (when normalize: false)
console.log(`Upload: ${event}%`);
} else {
// Rich progress with phase info
console.log(`[${event.phase}] ${event.progress}%`, event.detail ?? "");
}
},
});
console.log(out.video_id);
console.log(out.normalized); // true if normalization was applied
console.log(out.validation); // ValidationResult objectProgress Phases
When normalize is active, progress events include phase information:
| Phase | Progress Range | Description |
|-------|---------------|-------------|
| validating | 0–10 | TS integrity + manifest checks |
| probing | 10–20 | ffprobe analysis |
| normalizing | 20–40 | TS → MP4 remux |
| regenerating | 40–50 | MP4 → HLS regeneration |
| uploading | 50–95 | Segment + playlist upload |
| cleaning | 95–99 | Temp file cleanup |
| done | 100 | Complete |
Graceful Degradation
In 'auto' mode:
- If ffmpeg is not installed but normalization is needed, the SDK warns and uploads raw (does not fail).
- If probing fails, normalization continues with default segment duration.
- If normalization itself fails, the SDK falls back to raw upload.
In 'force' mode, all of the above become hard errors (throws VcdnHlsError).
Standalone Utilities
The normalize pipeline modules are exported for standalone use:
import {
validateHLS,
probeHLS,
normalizeHLS,
calculateHlsTime,
detectFfmpeg,
TempWorkspace,
} from "@vcdn/node";
// Validate without uploading
const validation = await validateHLS("/path/to/playlist.m3u8", "/path/to/root");
// Probe stream info
const probe = await probeHLS("/path/to/playlist.m3u8");
// Calculate optimal segment duration
const hlsTime = calculateHlsTime(probe.bitrateBps, 5); // 5MB maxError Handling
import { VcdnApiError, VcdnHlsError } from "@vcdn/node";
try {
await client.getVideo("video-id");
} catch (err) {
if (err instanceof VcdnApiError) {
console.error(err.status, err.code, err.message, err.detail);
}
}
try {
await hls.uploadHLS({ path: "/path/to/hls-output" });
} catch (err) {
if (err instanceof VcdnHlsError) {
console.error(err.code, err.message, err.detail);
}
}HLS Error Codes
| Code | Meaning |
|------|---------|
| CONFIG | Missing baseUrl / baseURL or apiKey. |
| NO_PLAYLIST | No .m3u8 under path. |
| MULTIPLE_PLAYLISTS | More than one .m3u8 under path. |
| MASTER_PLAYLIST | Master playlist (variants, no segments). |
| NO_TS_SEGMENTS | No .ts URIs in the playlist. |
| INVALID_SEGMENT_URI | Bad or empty segment URI after sanitize. |
| NESTED_SEGMENT_PATH | Segment URI contains a path separator. |
| PATH_TRAVERSAL | Segment resolves outside the playlist directory. |
| MISSING_SEGMENT / EMPTY_SEGMENT | File missing or zero size. |
| WAIT_TIMEOUT | Video did not become ready in time. |
| VIDEO_FAILED | Server reported failed status. |
| HTTP_ERROR / REQUEST_FAILED | HTTP or network failure. |
| VALIDATION_FAILED | Strict mode: HLS failed validation. |
| FFMPEG_NOT_FOUND | ffmpeg/ffprobe not in PATH and no custom path provided. |
| FFMPEG_FAILED | ffmpeg process exited non-zero. |
| FFMPEG_TIMEOUT | ffmpeg exceeded timeout. |
| NORMALIZE_FAILED | Normalize pipeline produced invalid output. |
| PROBE_FAILED | ffprobe could not analyze the input. |
Development
pnpm --dir node build
pnpm --dir node typecheck
pnpm --dir node testManual HLS upload example
Requires a built SDK, running upload-service, and a local HLS folder (one media .m3u8 + .ts segments).
pnpm --dir node build
# upload-service (e.g. make dev / docker compose, port 8082)
VCDN_API_KEY=dev-api-key VCDN_BASE_URL=http://localhost:8082 \
pnpm --dir node example:hls -- /path/to/hls-folderOptional env: NORMALIZE (auto|force|strict|false), TITLE, DEBUG=1. See examples/upload-hls.mjs.
