descript-import.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Descript import bridge. |
| 3 | * |
| 4 | * Pushes a finished MP4 (typically the HeyGen render output URL) into a Descript |
| 5 | * project so Descript's auto-edit pipeline can: |
| 6 | * - transcribe the audio |
| 7 | * - generate animated captions |
| 8 | * - cut filler words |
| 9 | * - propose 5 short-form clips per long-form |
| 10 | * |
| 11 | * Descript's public API surface is documented at https://www.descript.com/api. |
| 12 | * The exact endpoint shape may evolve; this module wraps a stable interface and |
| 13 | * isolates Paperclip from API drift. |
| 14 | * |
| 15 | * Two-step flow: |
| 16 | * 1. POST /v1/projects/{project_id}/imports — submit the MP4 URL, returns import_id |
| 17 | * 2. GET /v1/imports/{import_id} — poll until status == 'ready' |
| 18 | * |
| 19 | * @typedef {object} DescriptImportArgs |
| 20 | * @property {string} projectId Descript project ID for the project (born-free / store-free / knowtation). |
| 21 | * @property {string} videoUrl Public URL of the MP4 (HeyGen output). Descript fetches it server-side. |
| 22 | * @property {string} [title] Display title in Descript UI. |
| 23 | * @property {boolean} [autoTranscribe=true] |
| 24 | * @property {boolean} [autoGenerateShorts=true] |
| 25 | * @property {number} [pollIntervalMs=10000] |
| 26 | * @property {number} [pollTimeoutMs=600000] 10 min default. |
| 27 | * |
| 28 | * @typedef {object} DescriptClientOptions |
| 29 | * @property {string} apiKey |
| 30 | * @property {string} [baseUrl] Default 'https://api.descript.com'. |
| 31 | * @property {typeof fetch} [fetch] |
| 32 | * @property {(ms:number)=>Promise<void>} [sleep] |
| 33 | */ |
| 34 | |
| 35 | const DS_DEFAULT_BASE_URL = 'https://api.descript.com'; |
| 36 | |
| 37 | /** |
| 38 | * @param {DescriptClientOptions} opts |
| 39 | * @returns {{ |
| 40 | * import: (args: DescriptImportArgs) => Promise<{ import_id: string, project_id: string, status: 'ready', media_id: string, transcript_url?: string, shorts?: Array<{ id: string, start: number, end: number, suggested_title: string }> }>, |
| 41 | * submitImport: (args: DescriptImportArgs) => Promise<{ import_id: string }>, |
| 42 | * pollImport: (importId: string, opts?: { intervalMs?: number, timeoutMs?: number }) => Promise<any>, |
| 43 | * }} |
| 44 | */ |
| 45 | export function createDescriptClient(opts) { |
| 46 | const { |
| 47 | apiKey, |
| 48 | baseUrl = DS_DEFAULT_BASE_URL, |
| 49 | fetch: fetchImpl = globalThis.fetch, |
| 50 | sleep = (ms) => new Promise((r) => setTimeout(r, ms)), |
| 51 | } = opts; |
| 52 | |
| 53 | if (!apiKey) throw new Error('createDescriptClient: apiKey required'); |
| 54 | if (typeof fetchImpl !== 'function') throw new Error('createDescriptClient: fetch required'); |
| 55 | |
| 56 | const headers = () => ({ |
| 57 | Authorization: `Bearer ${apiKey}`, |
| 58 | 'Content-Type': 'application/json', |
| 59 | Accept: 'application/json', |
| 60 | }); |
| 61 | |
| 62 | async function submitImport(args) { |
| 63 | validate(args); |
| 64 | const body = { |
| 65 | source: { type: 'url', url: args.videoUrl }, |
| 66 | title: args.title ?? null, |
| 67 | auto_transcribe: args.autoTranscribe ?? true, |
| 68 | auto_generate_shorts: args.autoGenerateShorts ?? true, |
| 69 | }; |
| 70 | |
| 71 | const res = await fetchImpl( |
| 72 | `${baseUrl}/v1/projects/${encodeURIComponent(args.projectId)}/imports`, |
| 73 | { |
| 74 | method: 'POST', |
| 75 | headers: headers(), |
| 76 | body: JSON.stringify(body), |
| 77 | } |
| 78 | ); |
| 79 | |
| 80 | if (!res.ok) { |
| 81 | const errBody = await safeJson(res); |
| 82 | throw Object.assign( |
| 83 | new Error( |
| 84 | `descript_submit_${res.status}: ${errBody?.error || errBody?.message || 'import failed'}` |
| 85 | ), |
| 86 | { code: `DESCRIPT_SUBMIT_${res.status}`, status: res.status, body: errBody } |
| 87 | ); |
| 88 | } |
| 89 | |
| 90 | const data = await res.json(); |
| 91 | const importId = data?.import_id ?? data?.id; |
| 92 | if (!importId) { |
| 93 | throw Object.assign(new Error('descript_missing_import_id'), { |
| 94 | code: 'DESCRIPT_MISSING_IMPORT_ID', |
| 95 | body: data, |
| 96 | }); |
| 97 | } |
| 98 | return { import_id: String(importId) }; |
| 99 | } |
| 100 | |
| 101 | async function pollImport(importId, options = {}) { |
| 102 | const intervalMs = options.intervalMs ?? 10_000; |
| 103 | const timeoutMs = options.timeoutMs ?? 600_000; |
| 104 | const start = Date.now(); |
| 105 | |
| 106 | while (Date.now() - start < timeoutMs) { |
| 107 | const res = await fetchImpl(`${baseUrl}/v1/imports/${encodeURIComponent(importId)}`, { |
| 108 | method: 'GET', |
| 109 | headers: headers(), |
| 110 | }); |
| 111 | |
| 112 | if (!res.ok) { |
| 113 | const errBody = await safeJson(res); |
| 114 | throw Object.assign( |
| 115 | new Error(`descript_poll_${res.status}`), |
| 116 | { code: `DESCRIPT_POLL_${res.status}`, status: res.status, body: errBody } |
| 117 | ); |
| 118 | } |
| 119 | |
| 120 | const data = await res.json(); |
| 121 | const status = String(data?.status ?? '').toLowerCase(); |
| 122 | if (status === 'ready') { |
| 123 | return { |
| 124 | import_id: importId, |
| 125 | project_id: String(data?.project_id ?? ''), |
| 126 | status: 'ready', |
| 127 | media_id: String(data?.media_id ?? ''), |
| 128 | transcript_url: data?.transcript_url, |
| 129 | shorts: Array.isArray(data?.shorts) ? data.shorts : undefined, |
| 130 | }; |
| 131 | } |
| 132 | if (status === 'failed') { |
| 133 | throw Object.assign(new Error(`descript_import_failed: ${data?.error ?? 'unknown'}`), { |
| 134 | code: 'DESCRIPT_IMPORT_FAILED', |
| 135 | body: data, |
| 136 | }); |
| 137 | } |
| 138 | await sleep(intervalMs); |
| 139 | } |
| 140 | |
| 141 | throw Object.assign(new Error(`descript_poll_timeout after ${timeoutMs}ms`), { |
| 142 | code: 'DESCRIPT_POLL_TIMEOUT', |
| 143 | importId, |
| 144 | }); |
| 145 | } |
| 146 | |
| 147 | async function importVideo(args) { |
| 148 | const { import_id } = await submitImport(args); |
| 149 | return pollImport(import_id, { |
| 150 | intervalMs: args.pollIntervalMs, |
| 151 | timeoutMs: args.pollTimeoutMs, |
| 152 | }); |
| 153 | } |
| 154 | |
| 155 | return { import: importVideo, submitImport, pollImport }; |
| 156 | } |
| 157 | |
| 158 | function validate(args) { |
| 159 | if (!args || typeof args !== 'object') { |
| 160 | throw Object.assign(new Error('descript_invalid_args'), { code: 'DESCRIPT_INVALID_ARGS' }); |
| 161 | } |
| 162 | if (typeof args.projectId !== 'string' || !args.projectId.trim()) { |
| 163 | throw Object.assign(new Error('descript_invalid_project_id'), { |
| 164 | code: 'DESCRIPT_INVALID_PROJECT_ID', |
| 165 | }); |
| 166 | } |
| 167 | if (typeof args.videoUrl !== 'string' || !/^https?:\/\//.test(args.videoUrl)) { |
| 168 | throw Object.assign(new Error('descript_invalid_video_url'), { |
| 169 | code: 'DESCRIPT_INVALID_VIDEO_URL', |
| 170 | }); |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | async function safeJson(res) { |
| 175 | try { |
| 176 | return await res.json(); |
| 177 | } catch (_e) { |
| 178 | return null; |
| 179 | } |
| 180 | } |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
1 day ago