{"id":40075,"date":"2026-06-09T07:00:00","date_gmt":"2026-06-09T14:00:00","guid":{"rendered":"https:\/\/cloudinary.com\/blog\/?p=40075"},"modified":"2026-06-10T10:38:15","modified_gmt":"2026-06-10T17:38:15","slug":"vanilla-ts-video-upload-cloudinary","status":"publish","type":"post","link":"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary","title":{"rendered":"Upload, Tag, Transcribe, and Manage Videos With Cloudinary and Vanilla TypeScript \u2013 No Framework, No Backend"},"content":{"rendered":"<div class=\"wp-block-cloudinary-markdown \"><p>Managing video can feel challenging. Files are way too large, uploads time out, and progress feedback is nonexistent. Most tutorials hand you a library that hides everything, which means when something breaks, you have no idea why.<\/p>\n<p>In this guide, we\u2019ll build a complete video upload and gallery app using <strong>plain TypeScript<\/strong> (without a framework or backend) and Cloudinary. By the end, you\u2019ll understand exactly how video moves from a user\u2019s device to a hosted stream that plays in any browser, with AI-generated tags, searchable transcripts, rename, and delete.<\/p>\n<ul>\n<li>\n<strong>Live Demo:<\/strong> <a href=\"https:\/\/core-js-video-upload-cloudinary.vercel.app\/\">core-js-video-upload-cloudinary.vercel.app<\/a>\n<\/li>\n<li>\n<strong>Full Source:<\/strong> <a href=\"https:\/\/github.com\/musebe\/core-js-video-upload-cloudinary\">github.com\/musebe\/core-js-video-upload-cloudinary<\/a>\n<\/li>\n<\/ul>\n<h2>What We\u2019re Building<\/h2>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Page<\/th>\n<th>What It Does<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><strong>Upload<\/strong><\/td>\n<td>Drag-and-drop, chunked upload with live progress, pause\/resume\/cancel<\/td>\n<\/tr>\n<tr>\n<td><strong>Gallery<\/strong><\/td>\n<td>Video cards, inline playback, AI tags, searchable transcripts, rename, delete<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<h2>Why Chunked Uploads?<\/h2>\n<p>A normal file upload sends the whole file in one HTTP request. If your connection drops at 95%, you start over. <a href=\"https:\/\/cloudinary.com\/documentation\/upload_images#chunked_asset_upload\">Chunked uploads<\/a> slice the file into pieces and send each one individually. Cloudinary stitches them back together server-side.<\/p>\n<pre class=\"js-syntax-highlighted\"><code>Chunked upload flow\n\n1. Browser sends chunk 1\n   Range: 0 MB to 10 MB\n   Cloudinary responds: { done: false }\n\n2. Browser sends chunk 2\n   Range: 10 MB to 20 MB\n   Cloudinary responds: { done: false }\n\n3. Browser sends the final chunk\n   Cloudinary responds: { done: true, url: &quot;...&quot; }\n\nResult:\nCloudinary combines all chunks into one hosted video.\n<\/code><\/pre>\n<p>The secret glue is a single header: <code>X-Unique-Upload-Id<\/code>. Every chunk carries the same UUID so Cloudinary knows they belong to the same file.<\/p>\n<h2>Project Setup<\/h2>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-wrap-lines\"><span class=\"hljs-selector-tag\">npm<\/span> <span class=\"hljs-selector-tag\">create<\/span> <span class=\"hljs-selector-tag\">vite<\/span><span class=\"hljs-keyword\">@latest<\/span> core-js-video-upload-cloudinary -- --template vanilla-ts\ncd core-js-video-upload-cloudinary\nnpm install\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Your <code>package.json<\/code> scripts:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"JSON \/ JSON with Comments\" data-shcb-language-slug=\"json\"><span><code class=\"hljs language-json shcb-wrap-lines\">{\n  <span class=\"hljs-attr\">\"scripts\"<\/span>: {\n    <span class=\"hljs-attr\">\"dev\"<\/span>: <span class=\"hljs-string\">\"vite\"<\/span>,\n    <span class=\"hljs-attr\">\"build\"<\/span>: <span class=\"hljs-string\">\"tsc &amp;&amp; vite build\"<\/span>,\n    <span class=\"hljs-attr\">\"type-check\"<\/span>: <span class=\"hljs-string\">\"tsc --noEmit\"<\/span>\n  }\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JSON \/ JSON with Comments<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">json<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h2>Environment Variables<\/h2>\n<p>Create <code>.env<\/code> at the root:<\/p>\n<pre class=\"js-syntax-highlighted\"><code>VITE_CLOUDINARY_CLOUD_NAME=your-cloud-name\nVITE_CLOUDINARY_UPLOAD_PRESET=cld_video_upload\nVITE_CLOUDINARY_API_KEY=your-api-key\nVITE_CLOUDINARY_API_SECRET=your-api-secret\n<\/code><\/pre>\n<p>Vite exposes any variable prefixed <code>VITE_<\/code> to the browser via <code>import.meta.env<\/code>. No backend needed.<\/p>\n<h2>Cloudinary Setup<\/h2>\n<p>Sign up at <a href=\"https:\/\/cloudinary.com\/\">cloudinary.com<\/a>. Your <strong>cloud name<\/strong> is on the dashboard.  Copy it into <code>.env<\/code>.<\/p>\n<p>Next, create an <a href=\"https:\/\/cloudinary.com\/documentation\/upload_presets#unsigned_uploads\">unsigned upload preset<\/a><\/p>\n<p>An upload preset is a saved set of rules that Cloudinary applies to every upload.<\/p>\n<ol>\n<li>Go to <strong>Settings \u2192 Upload \u2192 Upload presets \u2192 Add upload preset<\/strong>.<\/li>\n<li>Set <strong>Signing Mode<\/strong> to <strong>Unsigned<\/strong>. This lets the browser upload without exposing your API secret<\/li>\n<li>Set <strong>Folder<\/strong> to <code>cld_video_upload<\/code>.<\/li>\n<li>Save and copy the preset name to <code>.env<\/code>.<\/li>\n<\/ol>\n<p>Now for the fun part. Enable AI add-ons, both of which require zero lines of processing code from you.<\/p>\n<p>The first add-on is <a href=\"https:\/\/cloudinary.com\/documentation\/google_automatic_video_tagging_addon#banner\">Google Video Tagging<\/a><\/p>\n<p>Every video will be automatically labelled, e.g., <code>food<\/code>, <code>kitchen<\/code>, <code>cooking<\/code> for a cooking video, and <code>soccer<\/code>, <code>athlete<\/code>, <code>stadium<\/code> for a sports clip.<\/p>\n<ol>\n<li>Go to <strong>Add-ons \u2192 Google AI Video Labeling \u2192 Subscribe<\/strong>.<\/li>\n<li>Open your upload preset \u2192 <strong>Google Video Tagging \u2192 Enable \u2192 Save<\/strong>.<\/li>\n<\/ol>\n<p>The next add-on is <a href=\"https:\/\/cloudinary.com\/documentation\/google_ai_video_transcription_addon\">Google Video Transcription<\/a><\/p>\n<p>Cloudinary listens to your video and generates captions automatically.<\/p>\n<ol>\n<li>Go to <strong>Add-ons \u2192 Google AI Video Transcription \u2192 Subscribe<\/strong>.<\/li>\n<li>Open your upload preset \u2192 <strong>Auto transcription \u2192 Enable both SRT and VTT \u2192 Save<\/strong>.<\/li>\n<\/ol>\n<p>This creates four files alongside every uploaded video: <code>.vtt<\/code>, <code>.en-US.vtt<\/code>, <code>.srt<\/code>, <code>.en-US.srt<\/code>, and a <code>.transcript<\/code> JSON, all ready to serve from Cloudinary\u2019s CDN.<\/p>\n<blockquote>\n<p>Both addons are configured <strong>in the preset<\/strong>, not in your code. Cloudinary processes them server-side after the upload lands.<\/p>\n<\/blockquote>\n<h2>The Upload Engine<\/h2>\n<p>The heart of the app is a <code>ChunkUploader<\/code> class in <a href=\"https:\/\/github.com\/musebe\/core-js-video-upload-cloudinary\/blob\/main\/src\/uploader.ts\"><code>src\/uploader.ts<\/code><\/a>. Here is the core loop:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ Split the file into 10 MB pieces<\/span>\n<span class=\"hljs-keyword\">const<\/span> chunks = <span class=\"hljs-keyword\">this<\/span>._buildChunks(file); <span class=\"hljs-comment\">\/\/ file.slice(start, end)<\/span>\n<span class=\"hljs-keyword\">const<\/span> uploadId = crypto.randomUUID(); <span class=\"hljs-comment\">\/\/ shared across all chunks<\/span>\n\n<span class=\"hljs-keyword\">for<\/span> (<span class=\"hljs-keyword\">const<\/span> chunk <span class=\"hljs-keyword\">of<\/span> chunks) {\n  <span class=\"hljs-keyword\">await<\/span> <span class=\"hljs-keyword\">this<\/span>._sendChunk(chunk, uploadId);\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Each chunk is sent as a <code>multipart\/form-data<\/code> POST:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">const<\/span> form = <span class=\"hljs-keyword\">new<\/span> FormData();\n\nform.append(<span class=\"hljs-string\">\"file\"<\/span>, chunk.blob);\nform.append(<span class=\"hljs-string\">\"upload_preset\"<\/span>, <span class=\"hljs-string\">\"cld_video_upload\"<\/span>);\n\nxhr.setRequestHeader(<span class=\"hljs-string\">\"X-Unique-Upload-Id\"<\/span>, uploadId);\nxhr.setRequestHeader(\n  <span class=\"hljs-string\">\"Content-Range\"<\/span>,\n  <span class=\"hljs-string\">`bytes <span class=\"hljs-subst\">${chunk.start}<\/span>-<span class=\"hljs-subst\">${chunk.end}<\/span>\/<span class=\"hljs-subst\">${chunk.totalBytes}<\/span>`<\/span>\n);\n\nxhr.open(<span class=\"hljs-string\">\"POST\"<\/span>, <span class=\"hljs-string\">`https:\/\/api.cloudinary.com\/v1_1\/<span class=\"hljs-subst\">${cloudName}<\/span>\/video\/upload`<\/span>);\nxhr.send(form);\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p><code>Content-Range<\/code> tells Cloudinary exactly where this piece fits. When the last chunk arrives, Cloudinary assembles the video and returns the full asset metadata, URL, duration, resolution, format, file size.<\/p>\n<h3>Retry on Failure<\/h3>\n<p>Networks are unreliable. Each chunk retries up to three times with exponential back-off:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ attempt 0 \u2192 wait 500 ms<\/span>\n<span class=\"hljs-comment\">\/\/ attempt 1 \u2192 wait 1 000 ms<\/span>\n<span class=\"hljs-comment\">\/\/ attempt 2 \u2192 wait 2 000 ms<\/span>\n<span class=\"hljs-keyword\">const<\/span> delay = baseMs * <span class=\"hljs-built_in\">Math<\/span>.pow(<span class=\"hljs-number\">2<\/span>, attempt); <span class=\"hljs-comment\">\/\/ 500, 1000, 2000<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h3>Pause and Resume<\/h3>\n<p>The upload loop checks a flag between chunks. Pause sets the flag; resume resolves a promise that the loop is blocking on. Cancel rejects it. The user always has full control.<\/p>\n<h3>Live Progress: \u2018Uploading Part X of Y\u2019<\/h3>\n<p>After each chunk, you\u2019ll fire a progress callback:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-wrap-lines\"><span class=\"hljs-selector-tag\">callbacks<\/span><span class=\"hljs-selector-class\">.onProgress<\/span>?.({\n  <span class=\"hljs-attribute\">currentChunk<\/span>: i + <span class=\"hljs-number\">1<\/span>, \/\/ <span class=\"hljs-string\">\"Part 2\"<\/span>\n  totalChunks, \/\/ <span class=\"hljs-string\">\"of 5\"<\/span>\n  percentage: <span class=\"hljs-number\">60<\/span>,\n  speedBytesPerSec: <span class=\"hljs-number\">2<\/span>_400_000,\n  etaSeconds: <span class=\"hljs-number\">12<\/span>,\n});\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The UI reads these values and updates the bar, speed display, and ETA in real time. This single callback is what turns a frustrating wait into a confident experience.<\/p>\n<h3>Add-on Quota Fallback<\/h3>\n<p>If you\u2019ve exhausted your AI add-on quota, Cloudinary returns HTTP 420. Rather than failing the whole upload, the engine catches it and automatically restarts as a <strong>signed request<\/strong>, no preset, no addon, but the video still lands safely in Cloudinary. The gallery marks it with a clear \u201cquota reached\u201d notice instead of silently breaking. See <a href=\"https:\/\/github.com\/musebe\/core-js-video-upload-cloudinary\/blob\/main\/src\/types.ts\"><code>src\/types.ts<\/code><\/a> for the <code>addonLimitReached<\/code> flag that flows through the whole system.<\/p>\n<h2>The Upload Page UI<\/h2>\n<p><a href=\"https:\/\/github.com\/musebe\/core-js-video-upload-cloudinary\/blob\/main\/index.html\"><code>index.html<\/code><\/a> + <a href=\"https:\/\/github.com\/musebe\/core-js-video-upload-cloudinary\/blob\/main\/src\/main.ts\"><code>src\/main.ts<\/code><\/a> handle drag-and-drop, file validation, and progress rendering.<\/p>\n<p>File validation runs before a single byte is sent:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ Accept only video MIME types; reject anything over 5 GB<\/span>\n<span class=\"hljs-keyword\">if<\/span> (!ACCEPTED_VIDEO_TYPES.has(file.type)) {\n  <span class=\"hljs-keyword\">return<\/span> { <span class=\"hljs-attr\">valid<\/span>: <span class=\"hljs-literal\">false<\/span>, <span class=\"hljs-attr\">reason<\/span>: <span class=\"hljs-string\">\"...\"<\/span> };\n}\n\n<span class=\"hljs-keyword\">if<\/span> (file.size &gt; <span class=\"hljs-number\">5<\/span> * <span class=\"hljs-number\">1024<\/span> ** <span class=\"hljs-number\">3<\/span>) {\n  <span class=\"hljs-keyword\">return<\/span> { <span class=\"hljs-attr\">valid<\/span>: <span class=\"hljs-literal\">false<\/span>, <span class=\"hljs-attr\">reason<\/span>: <span class=\"hljs-string\">\"...\"<\/span> };\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>After a successful upload the result section shows the video playing directly from Cloudinary\u2019s CDN, no self-hosting, no encoding pipeline, just a URL.<\/p>\n<h2>The Gallery Page<\/h2>\n<p>Every uploaded video is saved to <code>localStorage<\/code> with its public ID, URL, tags, and metadata. The gallery reads this on load and builds a card for each video. See <a href=\"https:\/\/github.com\/musebe\/core-js-video-upload-cloudinary\/blob\/main\/gallery.html\"><code>gallery.html<\/code><\/a> and <a href=\"https:\/\/github.com\/musebe\/core-js-video-upload-cloudinary\/blob\/main\/src\/gallery.ts\"><code>src\/gallery.ts<\/code><\/a>.<\/p>\n<h3>Thumbnail on Demand<\/h3>\n<p>Cloudinary transforms any video into a JPEG thumbnail with a single URL change:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\">secureUrl.replace(\n  <span class=\"hljs-string\">\"\/video\/upload\/\"<\/span>,\n  <span class=\"hljs-string\">\"\/video\/upload\/w_640,h_360,c_fill,so_0,q_auto,f_jpg\/\"<\/span>\n);\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p><code>c_fill<\/code> crops to fit. <code>so_0<\/code> grabs frame zero. <code>q_auto<\/code> picks the optimal quality. <code>f_jpg<\/code> converts to JPEG. No image processing code anywhere in your project.<\/p>\n<h3>Inline Player With Captions<\/h3>\n<p>When the play button is clicked, the thumbnail hides and the native <code>&lt;video&gt;<\/code> element takes over. Inject the VTT caption track Cloudinary generated:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">const<\/span> track = <span class=\"hljs-built_in\">document<\/span>.createElement(<span class=\"hljs-string\">\"track\"<\/span>);\n\ntrack.kind = <span class=\"hljs-string\">\"subtitles\"<\/span>;\ntrack.src = <span class=\"hljs-string\">`https:\/\/res.cloudinary.com\/<span class=\"hljs-subst\">${cloud}<\/span>\/raw\/upload\/<span class=\"hljs-subst\">${publicId}<\/span>.vtt`<\/span>;\ntrack.default = <span class=\"hljs-literal\">true<\/span>;\n\nvideoEl.appendChild(track);\nvideoEl.play();\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The browser\u2019s native CC button now works out of the box.<\/p>\n<h3>AI Tags<\/h3>\n<p>Tags come back asynchronously (Cloudinary processes them after the upload). A <strong>Refresh tags<\/strong> button calls the Admin API to check:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">const<\/span> res = <span class=\"hljs-keyword\">await<\/span> fetch(\n  <span class=\"hljs-string\">`\/api\/cloudinary\/v1_1\/<span class=\"hljs-subst\">${cloud}<\/span>\/resources\/video\/upload\/<span class=\"hljs-subst\">${publicId}<\/span>`<\/span>,\n  {\n    <span class=\"hljs-attr\">headers<\/span>: {\n      <span class=\"hljs-attr\">Authorization<\/span>: <span class=\"hljs-string\">`Basic <span class=\"hljs-subst\">${btoa(<span class=\"hljs-string\">`<span class=\"hljs-subst\">${apiKey}<\/span>:<span class=\"hljs-subst\">${apiSecret}<\/span>`<\/span>)}<\/span>`<\/span>,\n    },\n  }\n);\n\n<span class=\"hljs-keyword\">const<\/span> { tags } = <span class=\"hljs-keyword\">await<\/span> res.json(); <span class=\"hljs-comment\">\/\/ &#91;'sport', 'athlete', 'stadium']<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>When tags arrive they are saved back to <code>localStorage<\/code> and rendered as coloured pills on the card.<\/p>\n<h3>Word-Level Transcript Sync<\/h3>\n<p>Opening the transcript panel fetches the <code>.transcript<\/code> JSON from Cloudinary\u2019s CDN. Each word carries a <code>start_time<\/code> and <code>end_time<\/code>:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-11\" data-shcb-language-name=\"JSON \/ JSON with Comments\" data-shcb-language-slug=\"json\"><span><code class=\"hljs language-json shcb-wrap-lines\">{\n  <span class=\"hljs-attr\">\"word\"<\/span>: <span class=\"hljs-string\">\"welcome\"<\/span>,\n  <span class=\"hljs-attr\">\"start_time\"<\/span>: <span class=\"hljs-number\">0.4<\/span>,\n  <span class=\"hljs-attr\">\"end_time\"<\/span>: <span class=\"hljs-number\">0.9<\/span>\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-11\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JSON \/ JSON with Comments<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">json<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Render each word as a clickable <code>&lt;span&gt;<\/code>. The video\u2019s <code>timeupdate<\/code> event fires ~4x per second. You can find the matching word and highlight it:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-12\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\">videoEl.addEventListener(<span class=\"hljs-string\">\"timeupdate\"<\/span>, () =&gt; {\n  <span class=\"hljs-keyword\">const<\/span> t = videoEl.currentTime;\n  <span class=\"hljs-keyword\">const<\/span> active = words.find(<span class=\"hljs-function\">(<span class=\"hljs-params\">w<\/span>) =&gt;<\/span> t &gt;= w.start &amp;&amp; t &lt; w.end);\n\n  active?.classList.add(<span class=\"hljs-string\">\"tw--active\"<\/span>); <span class=\"hljs-comment\">\/\/ purple highlight<\/span>\n  active?.scrollIntoView({ <span class=\"hljs-attr\">behavior<\/span>: <span class=\"hljs-string\">\"smooth\"<\/span>, <span class=\"hljs-attr\">block<\/span>: <span class=\"hljs-string\">\"nearest\"<\/span> });\n});\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-12\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Click any word and the video jumps to that moment. It works because Cloudinary did the speech recognition, your code just reads the result.<\/p>\n<h2>Rename a Video<\/h2>\n<p>The gallery has an inline rename form. Clicking <strong>Save<\/strong> calls the Cloudinary Admin API via <a href=\"https:\/\/github.com\/musebe\/core-js-video-upload-cloudinary\/blob\/main\/src\/cloudinary-admin.ts\"><code>src\/cloudinary-admin.ts<\/code><\/a> to move the asset:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-13\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ Sign the request (SHA-1 via Web Crypto API, see src\/utils.ts)<\/span>\n<span class=\"hljs-keyword\">const<\/span> signature = <span class=\"hljs-keyword\">await<\/span> sha1Hex(\n  <span class=\"hljs-string\">`from_public_id=...&amp;to_public_id=...&amp;<span class=\"hljs-subst\">${apiSecret}<\/span>`<\/span>\n);\n\n<span class=\"hljs-keyword\">await<\/span> fetch(<span class=\"hljs-string\">`\/api\/cloudinary\/v1_1\/<span class=\"hljs-subst\">${cloud}<\/span>\/video\/rename`<\/span>, {\n  <span class=\"hljs-attr\">method<\/span>: <span class=\"hljs-string\">\"POST\"<\/span>,\n  <span class=\"hljs-attr\">body<\/span>: form, <span class=\"hljs-comment\">\/\/ from_public_id, to_public_id, api_key, signature, timestamp<\/span>\n});\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-13\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>After the main video is renamed we also rename all five supplementary files, the two VTTs, two SRTs, and the transcript JSON, so nothing goes stale:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-14\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">const<\/span> suffixes = &#91;<span class=\"hljs-string\">\".transcript\"<\/span>, <span class=\"hljs-string\">\".vtt\"<\/span>, <span class=\"hljs-string\">\".en-US.vtt\"<\/span>, <span class=\"hljs-string\">\".srt\"<\/span>, <span class=\"hljs-string\">\".en-US.srt\"<\/span>];\n\n<span class=\"hljs-keyword\">await<\/span> <span class=\"hljs-built_in\">Promise<\/span>.allSettled(\n  suffixes.map(<span class=\"hljs-function\">(<span class=\"hljs-params\">s<\/span>) =&gt;<\/span> renameRaw(<span class=\"hljs-string\">`<span class=\"hljs-subst\">${oldId}<\/span><span class=\"hljs-subst\">${s}<\/span>`<\/span>, <span class=\"hljs-string\">`<span class=\"hljs-subst\">${newId}<\/span><span class=\"hljs-subst\">${s}<\/span>`<\/span>))\n);\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-14\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p><code>Promise.allSettled<\/code> means a missing file never blocks the rename.<\/p>\n<h2>Delete a Video<\/h2>\n<p>The <strong>Delete<\/strong> button removes the video and every supplementary file from Cloudinary via <a href=\"https:\/\/github.com\/musebe\/core-js-video-upload-cloudinary\/blob\/main\/src\/cloudinary-admin.ts\"><code>src\/cloudinary-admin.ts<\/code><\/a>, then clears the localStorage entry:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-15\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ Delete the video<\/span>\n<span class=\"hljs-keyword\">await<\/span> fetch(<span class=\"hljs-string\">`\/api\/cloudinary\/v1_1\/<span class=\"hljs-subst\">${cloud}<\/span>\/video\/destroy`<\/span>, {\n  <span class=\"hljs-attr\">method<\/span>: <span class=\"hljs-string\">\"POST\"<\/span>,\n  <span class=\"hljs-attr\">body<\/span>: form, <span class=\"hljs-comment\">\/\/ public_id, api_key, signature, invalidate: true<\/span>\n});\n\n<span class=\"hljs-comment\">\/\/ Delete VTT, SRT, transcript, ignore \"not found\" errors<\/span>\n<span class=\"hljs-keyword\">await<\/span> <span class=\"hljs-built_in\">Promise<\/span>.allSettled(suffixes.map(<span class=\"hljs-function\">(<span class=\"hljs-params\">s<\/span>) =&gt;<\/span> deleteRaw(<span class=\"hljs-string\">`<span class=\"hljs-subst\">${publicId}<\/span><span class=\"hljs-subst\">${s}<\/span>`<\/span>)));\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-15\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p><code>invalidate: true<\/code> tells Cloudinary to purge the CDN cache immediately so the old URL stops working.<\/p>\n<h2>Deploy to Vercel<\/h2>\n<p>A static Vite build works on Vercel with two additions.<\/p>\n<p><strong>Tell Vite about both pages<\/strong> (<a href=\"https:\/\/github.com\/musebe\/core-js-video-upload-cloudinary\/blob\/main\/vite.config.ts\"><code>vite.config.ts<\/code><\/a>):<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-16\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-wrap-lines\"><span class=\"hljs-selector-tag\">rollupOptions<\/span>: {\n  <span class=\"hljs-attribute\">input<\/span>: {\n    main: <span class=\"hljs-built_in\">resolve<\/span>(__dirname, <span class=\"hljs-string\">\"index.html\"<\/span>),\n    gallery: <span class=\"hljs-built_in\">resolve<\/span>(__dirname, <span class=\"hljs-string\">\"gallery.html\"<\/span>),\n  },\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-16\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p><strong>Proxy Admin API calls in production<\/strong> (<a href=\"https:\/\/github.com\/musebe\/core-js-video-upload-cloudinary\/blob\/main\/vercel.json\"><code>vercel.json<\/code><\/a>):<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-17\" data-shcb-language-name=\"JSON \/ JSON with Comments\" data-shcb-language-slug=\"json\"><span><code class=\"hljs language-json shcb-wrap-lines\">{\n  <span class=\"hljs-attr\">\"rewrites\"<\/span>: &#91;\n    {\n      <span class=\"hljs-attr\">\"source\"<\/span>: <span class=\"hljs-string\">\"\/api\/cloudinary\/:path*\"<\/span>,\n      <span class=\"hljs-attr\">\"destination\"<\/span>: <span class=\"hljs-string\">\"https:\/\/api.cloudinary.com\/:path*\"<\/span>\n    }\n  ]\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-17\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JSON \/ JSON with Comments<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">json<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The proxy avoids CORS errors when the browser calls the Cloudinary Admin API directly. In development, Vite\u2019s <code>server.proxy<\/code> does the same thing.<\/p>\n<p>Add your four environment variables in the Vercel project settings, push, and you\u2019re live.<\/p>\n<h2>What Cloudinary Handled for You<\/h2>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Task<\/th>\n<th>Your Code<\/th>\n<th>Cloudinary<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Reassemble file chunks<\/td>\n<td>Send with <code>X-Unique-Upload-Id<\/code><\/td>\n<td>\u2713<\/td>\n<\/tr>\n<tr>\n<td>Generate thumbnail<\/td>\n<td>Build the URL<\/td>\n<td>\u2713<\/td>\n<\/tr>\n<tr>\n<td>Transcode for all devices<\/td>\n<td>Nothing<\/td>\n<td>\u2713<\/td>\n<\/tr>\n<tr>\n<td>Speech-to-text captions<\/td>\n<td>Attach the <code>&lt;track&gt;<\/code><\/td>\n<td>\u2713<\/td>\n<\/tr>\n<tr>\n<td>Word-level timestamps<\/td>\n<td>Read the JSON<\/td>\n<td>\u2713<\/td>\n<\/tr>\n<tr>\n<td>AI scene\/object labels<\/td>\n<td>Read the tags<\/td>\n<td>\u2713<\/td>\n<\/tr>\n<tr>\n<td>Global CDN delivery<\/td>\n<td>Use the URL<\/td>\n<td>\u2713<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<p>The pattern is consistent: You write the wiring, Cloudinary does the processing.<\/p>\n<h2>Where to Go Next<\/h2>\n<ul>\n<li>Add a folder structure in Cloudinary to organize videos by user or category.<\/li>\n<li>Use Cloudinary\u2019s transformation URL to generate video previews (animated GIF or short MP4 clip).<\/li>\n<li>Move the API secret to a serverless function so it never touches the browser.<\/li>\n<\/ul>\n<p>Ready to start building? <a href=\"https:\/\/cloudinary.com\/users\/register_free\">Sign up for a free Cloudinary account<\/a> today to get started.<\/p>\n<hr \/>\n<ul>\n<li>\n<strong>Full Source Code:<\/strong> <a href=\"https:\/\/github.com\/musebe\/core-js-video-upload-cloudinary\">github.com\/musebe\/core-js-video-upload-cloudinary<\/a>\n<\/li>\n<li>\n<strong>Live Demo:<\/strong> <a href=\"https:\/\/core-js-video-upload-cloudinary.vercel.app\/\">core-js-video-upload-cloudinary.vercel.app<\/a>\n<\/li>\n<\/ul>\n<style>\n    .faqs {padding: 30px 60px; margin-top: 40px;background: var(--color-background-offset);border-radius: 20px;}\n    #frequently_asked_questions {margin-bottom: 20px;}\n    .question {margin-bottom: 20px;}\n<\/style>\n<div class=\"faqs\">\n<h2>Frequently Asked Questions<\/h2>\n<div class=\"question\">\n<p><b>How do chunked uploads improve large video file reliability?<\/b><\/p>\nSlicing videos into smaller pieces allows you to upload them sequentially. If a network drops, your application only retries the failed chunk rather than restarting the entire file upload from the beginning.\n<\/div>\n<div class=\"question\">\n<p><b>How can I automatically generate captions and transcriptions for uploaded videos?<\/b><\/p>\nYou can enable the Google AI Video Transcription add-on in your Cloudinary upload preset. Cloudinary automatically processes speech-to-text in the background and delivers standard subtitle files ready for the browser.\n<\/div>\n<div class=\"question\">\n<p><b>What is the benefit of using an unique upload identifier in headers?<\/b><\/p>\nPassing the X-Unique-Upload-Id header alongside a Content-Range header tells Cloudinary which file the chunks belong to. It allows Cloudinary to seamlessly stitch the pieces together once the final chunk arrives.\n<\/div>\n<div class=\"question\">\n<p><b>How do you build an interactive, word-level synchronized transcript in vanilla TypeScript?<\/b><\/p>\nYou fetch the auto-generated JSON transcript from the CDN and map each word to a timed span. Listening to the video player&#8217;s timeupdate event allows you to highlight the active word dynamically in real time.\n<\/div>\n<div class=\"question\">\n<p><b>Can I customize video thumbnails on demand without processing libraries?<\/b><\/p>\nYes. Changing the delivery URL string with parameters like w_640, h_360, c_fill, and so_0 allows Cloudinary to instantly capture the first frame, resize it, and output a lightweight image.\n<\/div>\n<\/div>\n<\/div>","protected":false},"excerpt":{"rendered":"","protected":false},"author":87,"featured_media":40083,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"_cloudinary_featured_overwrite":false,"footnotes":""},"categories":[1],"tags":[286,373,303],"class_list":["post-40075","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-uncategorized","tag-tagging","tag-upload","tag-video"],"acf":[],"yoast_head":"<!-- This site is optimized with the Yoast SEO Premium plugin v25.6 (Yoast SEO v26.9) - https:\/\/yoast.com\/product\/yoast-seo-premium-wordpress\/ -->\n<title>TypeScript Video Upload &amp; Management With Cloudinary<\/title>\n<meta name=\"description\" content=\"Build a framework-free video uploader in TypeScript. Implement chunked uploads, resume support, and unlock Cloudinary AI tagging and auto-transcription.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Upload, Tag, Transcribe, and Manage Videos With Cloudinary and Vanilla TypeScript \u2013 No Framework, No Backend\" \/>\n<meta property=\"og:description\" content=\"Build a framework-free video uploader in TypeScript. Implement chunked uploads, resume support, and unlock Cloudinary AI tagging and auto-transcription.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary\" \/>\n<meta property=\"og:site_name\" content=\"Cloudinary Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-06-09T14:00:00+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-06-10T17:38:15+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1780467101\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend.jpg?_i=AA\" \/>\n\t<meta property=\"og:image:width\" content=\"2000\" \/>\n\t<meta property=\"og:image:height\" content=\"1100\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/jpeg\" \/>\n<meta name=\"author\" content=\"melindapham\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\/\/schema.org\",\"@graph\":[{\"@type\":\"NewsArticle\",\"@id\":\"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary#article\",\"isPartOf\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary\"},\"author\":{\"name\":\"melindapham\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9\"},\"headline\":\"Upload, Tag, Transcribe, and Manage Videos With Cloudinary and Vanilla TypeScript \u2013 No Framework, No Backend\",\"datePublished\":\"2026-06-09T14:00:00+00:00\",\"dateModified\":\"2026-06-10T17:38:15+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary\"},\"wordCount\":15,\"publisher\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#organization\"},\"image\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary#primaryimage\"},\"thumbnailUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1780467101\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend.jpg?_i=AA\",\"keywords\":[\"Tagging\",\"Upload\",\"Video\"],\"inLanguage\":\"en-US\",\"copyrightYear\":\"2026\",\"copyrightHolder\":{\"@id\":\"https:\/\/cloudinary.com\/#organization\"}},{\"@type\":\"WebPage\",\"@id\":\"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary\",\"url\":\"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary\",\"name\":\"TypeScript Video Upload & Management With Cloudinary\",\"isPartOf\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary#primaryimage\"},\"image\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary#primaryimage\"},\"thumbnailUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1780467101\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend.jpg?_i=AA\",\"datePublished\":\"2026-06-09T14:00:00+00:00\",\"dateModified\":\"2026-06-10T17:38:15+00:00\",\"description\":\"Build a framework-free video uploader in TypeScript. Implement chunked uploads, resume support, and unlock Cloudinary AI tagging and auto-transcription.\",\"breadcrumb\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary#primaryimage\",\"url\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1780467101\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend.jpg?_i=AA\",\"contentUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1780467101\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend.jpg?_i=AA\",\"width\":2000,\"height\":1100},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\/\/cloudinary.com\/blog\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Upload, Tag, Transcribe, and Manage Videos With Cloudinary and Vanilla TypeScript \u2013 No Framework, No Backend\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#website\",\"url\":\"https:\/\/cloudinary.com\/blog\/\",\"name\":\"Cloudinary Blog\",\"description\":\"\",\"publisher\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#organization\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\/\/cloudinary.com\/blog\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#organization\",\"name\":\"Cloudinary Blog\",\"url\":\"https:\/\/cloudinary.com\/blog\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/\",\"url\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA\",\"contentUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA\",\"width\":312,\"height\":60,\"caption\":\"Cloudinary Blog\"},\"image\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/\"}},{\"@type\":\"Person\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9\",\"name\":\"melindapham\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/image\/\",\"url\":\"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g\",\"contentUrl\":\"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g\",\"caption\":\"melindapham\"}}]}<\/script>\n<!-- \/ Yoast SEO Premium plugin. -->","yoast_head_json":{"title":"TypeScript Video Upload & Management With Cloudinary","description":"Build a framework-free video uploader in TypeScript. Implement chunked uploads, resume support, and unlock Cloudinary AI tagging and auto-transcription.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary","og_locale":"en_US","og_type":"article","og_title":"Upload, Tag, Transcribe, and Manage Videos With Cloudinary and Vanilla TypeScript \u2013 No Framework, No Backend","og_description":"Build a framework-free video uploader in TypeScript. Implement chunked uploads, resume support, and unlock Cloudinary AI tagging and auto-transcription.","og_url":"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary","og_site_name":"Cloudinary Blog","article_published_time":"2026-06-09T14:00:00+00:00","article_modified_time":"2026-06-10T17:38:15+00:00","og_image":[{"width":2000,"height":1100,"url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1780467101\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend.jpg?_i=AA","type":"image\/jpeg"}],"author":"melindapham","twitter_card":"summary_large_image","schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"NewsArticle","@id":"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary#article","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary"},"author":{"name":"melindapham","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9"},"headline":"Upload, Tag, Transcribe, and Manage Videos With Cloudinary and Vanilla TypeScript \u2013 No Framework, No Backend","datePublished":"2026-06-09T14:00:00+00:00","dateModified":"2026-06-10T17:38:15+00:00","mainEntityOfPage":{"@id":"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary"},"wordCount":15,"publisher":{"@id":"https:\/\/cloudinary.com\/blog\/#organization"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1780467101\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend.jpg?_i=AA","keywords":["Tagging","Upload","Video"],"inLanguage":"en-US","copyrightYear":"2026","copyrightHolder":{"@id":"https:\/\/cloudinary.com\/#organization"}},{"@type":"WebPage","@id":"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary","url":"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary","name":"TypeScript Video Upload & Management With Cloudinary","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary#primaryimage"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1780467101\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend.jpg?_i=AA","datePublished":"2026-06-09T14:00:00+00:00","dateModified":"2026-06-10T17:38:15+00:00","description":"Build a framework-free video uploader in TypeScript. Implement chunked uploads, resume support, and unlock Cloudinary AI tagging and auto-transcription.","breadcrumb":{"@id":"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary#primaryimage","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1780467101\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend.jpg?_i=AA","contentUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1780467101\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend.jpg?_i=AA","width":2000,"height":1100},{"@type":"BreadcrumbList","@id":"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/cloudinary.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Upload, Tag, Transcribe, and Manage Videos With Cloudinary and Vanilla TypeScript \u2013 No Framework, No Backend"}]},{"@type":"WebSite","@id":"https:\/\/cloudinary.com\/blog\/#website","url":"https:\/\/cloudinary.com\/blog\/","name":"Cloudinary Blog","description":"","publisher":{"@id":"https:\/\/cloudinary.com\/blog\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/cloudinary.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/cloudinary.com\/blog\/#organization","name":"Cloudinary Blog","url":"https:\/\/cloudinary.com\/blog\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA","contentUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA","width":312,"height":60,"caption":"Cloudinary Blog"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/"}},{"@type":"Person","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9","name":"melindapham","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/image\/","url":"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g","caption":"melindapham"}}]}},"parsely":{"version":"1.1.0","canonical_url":"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary","smart_links":{"inbound":0,"outbound":0},"traffic_boost_suggestions_count":0,"meta":{"@context":"https:\/\/schema.org","@type":"NewsArticle","headline":"Upload, Tag, Transcribe, and Manage Videos With Cloudinary and Vanilla TypeScript \u2013 No Framework, No Backend","url":"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary","mainEntityOfPage":{"@type":"WebPage","@id":"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1780467101\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend.jpg?_i=AA&w=150&h=150&crop=1","image":{"@type":"ImageObject","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1780467101\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend.jpg?_i=AA"},"articleSection":"Uncategorized","author":[{"@type":"Person","name":"melindapham"}],"creator":["melindapham"],"publisher":{"@type":"Organization","name":"Cloudinary Blog","logo":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA"},"keywords":["tagging","upload","video"],"dateCreated":"2026-06-09T14:00:00Z","datePublished":"2026-06-09T14:00:00Z","dateModified":"2026-06-10T17:38:15Z"},"rendered":"<meta name=\"parsely-title\" content=\"Upload, Tag, Transcribe, and Manage Videos With Cloudinary and Vanilla TypeScript \u2013 No Framework, No Backend\" \/>\n<meta name=\"parsely-link\" content=\"https:\/\/cloudinary.com\/blog\/vanilla-ts-video-upload-cloudinary\" \/>\n<meta name=\"parsely-type\" content=\"post\" \/>\n<meta name=\"parsely-image-url\" content=\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1780467101\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend.jpg?_i=AA&w=150&amp;h=150&amp;crop=1\" \/>\n<meta name=\"parsely-pub-date\" content=\"2026-06-09T14:00:00Z\" \/>\n<meta name=\"parsely-section\" content=\"Uncategorized\" \/>\n<meta name=\"parsely-tags\" content=\"tagging,upload,video\" \/>\n<meta name=\"parsely-author\" content=\"melindapham\" \/>","tracker_url":"https:\/\/cdn.parsely.com\/keys\/cloudinary.com\/p.js"},"jetpack_featured_media_url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1780467101\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend\/Blog_Upload_Tag_Transcribe_and_Manage_Videos_with_Cloudinary_and_Vanilla_TypeScript_No_Framework_No_Backend.jpg?_i=AA","_links":{"self":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40075","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/users\/87"}],"replies":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/comments?post=40075"}],"version-history":[{"count":1,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40075\/revisions"}],"predecessor-version":[{"id":40077,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40075\/revisions\/40077"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media\/40083"}],"wp:attachment":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media?parent=40075"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/categories?post=40075"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/tags?post=40075"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}