@growth-labs/cms 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +30 -3
  2. package/dist/engine/foundry-dispatch.d.ts +5 -1
  3. package/dist/engine/foundry-dispatch.d.ts.map +1 -1
  4. package/dist/engine/foundry-dispatch.js +19 -3
  5. package/dist/engine/foundry-dispatch.js.map +1 -1
  6. package/dist/engine/index.d.ts +1 -1
  7. package/dist/engine/index.d.ts.map +1 -1
  8. package/dist/engine/index.js +1 -1
  9. package/dist/engine/index.js.map +1 -1
  10. package/dist/engine/published-content.d.ts.map +1 -1
  11. package/dist/engine/published-content.js +29 -12
  12. package/dist/engine/published-content.js.map +1 -1
  13. package/dist/engine/publisher.d.ts +4 -0
  14. package/dist/engine/publisher.d.ts.map +1 -1
  15. package/dist/engine/publisher.js +190 -54
  16. package/dist/engine/publisher.js.map +1 -1
  17. package/dist/providers/types.d.ts +1 -0
  18. package/dist/providers/types.d.ts.map +1 -1
  19. package/dist/routes/content.d.ts.map +1 -1
  20. package/dist/routes/content.js +76 -16
  21. package/dist/routes/content.js.map +1 -1
  22. package/dist/routes/context.d.ts +1 -0
  23. package/dist/routes/context.d.ts.map +1 -1
  24. package/dist/routes/context.js.map +1 -1
  25. package/dist/routes/media.d.ts.map +1 -1
  26. package/dist/routes/media.js +53 -31
  27. package/dist/routes/media.js.map +1 -1
  28. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  29. package/dist/ui/editor/ContentForm.js +65 -10
  30. package/dist/ui/editor/ContentForm.js.map +1 -1
  31. package/dist/ui/editor/content-payload.d.ts +2 -0
  32. package/dist/ui/editor/content-payload.d.ts.map +1 -1
  33. package/dist/ui/editor/content-payload.js +15 -4
  34. package/dist/ui/editor/content-payload.js.map +1 -1
  35. package/dist/ui/editor/editor-media-duration.d.ts +24 -0
  36. package/dist/ui/editor/editor-media-duration.d.ts.map +1 -0
  37. package/dist/ui/editor/editor-media-duration.js +51 -0
  38. package/dist/ui/editor/editor-media-duration.js.map +1 -0
  39. package/dist/ui/screens/MediaScreen.d.ts.map +1 -1
  40. package/dist/ui/screens/MediaScreen.js +8 -1
  41. package/dist/ui/screens/MediaScreen.js.map +1 -1
  42. package/dist/ui/screens/media-upload.d.ts +9 -2
  43. package/dist/ui/screens/media-upload.d.ts.map +1 -1
  44. package/dist/ui/screens/media-upload.js +24 -0
  45. package/dist/ui/screens/media-upload.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/engine/foundry-dispatch.ts +32 -4
  48. package/src/engine/index.ts +1 -0
  49. package/src/engine/published-content.ts +32 -16
  50. package/src/engine/publisher.ts +297 -97
  51. package/src/providers/types.ts +1 -0
  52. package/src/routes/content.ts +91 -16
  53. package/src/routes/context.ts +1 -0
  54. package/src/routes/media.ts +63 -41
  55. package/src/ui/editor/ContentForm.tsx +121 -4
  56. package/src/ui/editor/content-payload.ts +20 -2
  57. package/src/ui/editor/editor-media-duration.ts +75 -0
  58. package/src/ui/screens/MediaScreen.tsx +13 -2
  59. package/src/ui/screens/media-upload.ts +41 -2
@@ -18,7 +18,7 @@
18
18
  //
19
19
  // Compile-gated: all Tiptap/React usage verified by `pnpm run build` (tsc).
20
20
 
21
- import { useCallback, useEffect, useRef, useState } from 'react'
21
+ import { useCallback, useEffect, useId, useRef, useState } from 'react'
22
22
  import { countWords, estimateReadTime } from '../../engine/publisher.js'
23
23
  import type { ContentStatus, ContentType } from '../../schema/types.js'
24
24
  import { Icon } from '../icons.js'
@@ -31,8 +31,10 @@ import {
31
31
  type ContentDraftFields,
32
32
  canCreateContentDraft,
33
33
  foundryDispatchSourceForDraft,
34
+ normalizeMediaDurationSeconds,
34
35
  slugifyTitle,
35
36
  } from './content-payload.js'
37
+ import { measureEditorMediaDurationSeconds } from './editor-media-duration.js'
36
38
  import { uploadEditorImage } from './editor-media-upload.js'
37
39
  import { Rte } from './Rte.js'
38
40
  import { docToMarkdown } from './serialize.js'
@@ -115,6 +117,7 @@ interface ExistingContentPayload {
115
117
  processing_source_kind?: string | null
116
118
  transcript?: string | null
117
119
  audio_r2_key?: string | null
120
+ duration_seconds?: number | null
118
121
  }
119
122
 
120
123
  interface ExistingContentResponse {
@@ -147,6 +150,7 @@ interface FormState {
147
150
  thumbnailUploadStatus: 'idle' | 'uploading' | 'staged'
148
151
  thumbnailUploadError: string | null
149
152
  audioUrl: string
153
+ durationSeconds: number | null
150
154
  audioUploadStatus: 'idle' | 'uploading' | 'staged'
151
155
  audioUploadError: string | null
152
156
  videoUploadStatus: 'idle' | 'uploading' | 'staged'
@@ -191,6 +195,7 @@ function createEmptyFormState(docId: string | null): FormState {
191
195
  thumbnailUploadStatus: 'idle',
192
196
  thumbnailUploadError: null,
193
197
  audioUrl: '',
198
+ durationSeconds: null,
194
199
  audioUploadStatus: 'idle',
195
200
  audioUploadError: null,
196
201
  videoUploadStatus: 'idle',
@@ -284,7 +289,17 @@ export function ContentForm({
284
289
  async (id: string, draft: ContentDraftFields) => {
285
290
  const source = foundryDispatchSourceForDraft(contentType, draft)
286
291
  if (!source) return
287
- const queueKey = `${contentType}:${id}:${source}`
292
+ const durationSeconds = normalizeMediaDurationSeconds(draft.durationSeconds)
293
+ if (durationSeconds === null) {
294
+ setForm((prev) => ({
295
+ ...prev,
296
+ videoPipelineStatus: 'idle',
297
+ videoPipelineError:
298
+ 'Enter the authoritative media duration in whole seconds before queueing.',
299
+ }))
300
+ return
301
+ }
302
+ const queueKey = `${contentType}:${id}:${source}:${durationSeconds}`
288
303
  if (queuedVideoSources.current.has(queueKey)) return
289
304
 
290
305
  setForm((prev) => ({
@@ -326,9 +341,14 @@ export function ContentForm({
326
341
 
327
342
  if (id === null) {
328
343
  if (!canCreateContentDraft(contentType, draft)) {
344
+ const mediaSource = foundryDispatchSourceForDraft(contentType, draft)
345
+ const missingMediaDuration =
346
+ mediaSource && normalizeMediaDurationSeconds(draft.durationSeconds) === null
329
347
  setForm((prev) => ({
330
348
  ...prev,
331
- saveError: 'Add the required title and content before saving.',
349
+ saveError: missingMediaDuration
350
+ ? 'Enter the authoritative media duration in whole seconds before saving.'
351
+ : 'Add the required title and content before saving.',
332
352
  }))
333
353
  return
334
354
  }
@@ -490,6 +510,7 @@ export function ContentForm({
490
510
  const next = {
491
511
  ...latestForm.current,
492
512
  videoUrl: value,
513
+ durationSeconds: null,
493
514
  videoSourceKind: null,
494
515
  videoId: null,
495
516
  videoPipelineStatus: 'idle' as const,
@@ -501,7 +522,28 @@ export function ContentForm({
501
522
  }
502
523
 
503
524
  function handleAudioUrlChange(value: string) {
504
- const next = { ...latestForm.current, audioUrl: value }
525
+ const next = {
526
+ ...latestForm.current,
527
+ audioUrl: value,
528
+ durationSeconds: null,
529
+ videoPipelineStatus: 'idle' as const,
530
+ videoPipelineError: null,
531
+ }
532
+ setForm(next)
533
+ publishDraft(next)
534
+ scheduleAutosave(next)
535
+ }
536
+
537
+ function handleDurationSecondsChange(value: string) {
538
+ const parsed = value.trim() === '' ? null : Number(value)
539
+ const durationSeconds =
540
+ typeof parsed === 'number' && Number.isInteger(parsed) && parsed > 0 ? parsed : null
541
+ const next = {
542
+ ...latestForm.current,
543
+ durationSeconds,
544
+ videoPipelineStatus: 'idle' as const,
545
+ videoPipelineError: null,
546
+ }
505
547
  setForm(next)
506
548
  publishDraft(next)
507
549
  scheduleAutosave(next)
@@ -546,17 +588,24 @@ export function ContentForm({
546
588
  videoPipelineError: null,
547
589
  }))
548
590
  try {
591
+ const measuredDurationSeconds = await measureEditorMediaDurationSeconds(file, 'video')
549
592
  const uploaded = await uploadMediaAsset({
550
593
  kind: 'video',
551
594
  file,
552
595
  slug: baseSlug || undefined,
596
+ durationSeconds: measuredDurationSeconds,
553
597
  })
554
598
  if (!uploaded.url) throw new Error('Video upload did not return a source URL')
599
+ const durationSeconds = normalizeMediaDurationSeconds(uploaded.durationSeconds)
600
+ if (durationSeconds === null) {
601
+ throw new Error('Video upload did not return its verified duration')
602
+ }
555
603
  const next = {
556
604
  ...latestForm.current,
557
605
  videoUrl: uploaded.url,
558
606
  videoSourceKind: uploaded.sourceKind ?? 'http_url',
559
607
  videoId: uploaded.videoId ?? baseSlug,
608
+ durationSeconds,
560
609
  videoUploadStatus: 'staged' as const,
561
610
  videoUploadError: null,
562
611
  videoPipelineStatus: 'idle' as const,
@@ -656,14 +705,21 @@ export function ContentForm({
656
705
  audioUploadError: null,
657
706
  }))
658
707
  try {
708
+ const measuredDurationSeconds = await measureEditorMediaDurationSeconds(file, 'audio')
659
709
  const uploaded = await uploadMediaAsset({
660
710
  kind: 'podcast',
661
711
  file,
662
712
  slug: baseSlug || undefined,
713
+ durationSeconds: measuredDurationSeconds,
663
714
  })
715
+ const durationSeconds = normalizeMediaDurationSeconds(uploaded.durationSeconds)
716
+ if (durationSeconds === null) {
717
+ throw new Error('Podcast upload did not return its verified duration')
718
+ }
664
719
  const next = {
665
720
  ...latestForm.current,
666
721
  audioUrl: uploaded.id,
722
+ durationSeconds,
667
723
  audioUploadStatus: 'staged' as const,
668
724
  audioUploadError: null,
669
725
  }
@@ -774,7 +830,9 @@ export function ContentForm({
774
830
  <div style={mediaBlockStack}>
775
831
  <VideoBlock
776
832
  videoUrl={form.videoUrl}
833
+ durationSeconds={form.durationSeconds}
777
834
  onVideoUrlChange={handleVideoUrlChange}
835
+ onDurationSecondsChange={handleDurationSecondsChange}
778
836
  onVideoFileUpload={handleVideoFileUpload}
779
837
  uploadStatus={form.videoUploadStatus}
780
838
  uploadError={form.videoUploadError}
@@ -801,7 +859,9 @@ export function ContentForm({
801
859
  <div style={mediaBlockStack}>
802
860
  <AudioBlock
803
861
  audioUrl={form.audioUrl}
862
+ durationSeconds={form.durationSeconds}
804
863
  onAudioUrlChange={handleAudioUrlChange}
864
+ onDurationSecondsChange={handleDurationSecondsChange}
805
865
  onAudioFileUpload={handleAudioFileUpload}
806
866
  uploadStatus={form.audioUploadStatus}
807
867
  uploadError={form.audioUploadError}
@@ -975,7 +1035,9 @@ function ImageAssetBlock({
975
1035
 
976
1036
  function VideoBlock({
977
1037
  videoUrl,
1038
+ durationSeconds,
978
1039
  onVideoUrlChange,
1040
+ onDurationSecondsChange,
979
1041
  onVideoFileUpload,
980
1042
  uploadStatus,
981
1043
  uploadError,
@@ -983,7 +1045,9 @@ function VideoBlock({
983
1045
  pipelineError,
984
1046
  }: {
985
1047
  videoUrl: string
1048
+ durationSeconds: number | null
986
1049
  onVideoUrlChange: (url: string) => void
1050
+ onDurationSecondsChange: (value: string) => void
987
1051
  onVideoFileUpload: (file: File) => void | Promise<void>
988
1052
  uploadStatus: 'idle' | 'uploading' | 'staged'
989
1053
  uploadError: string | null
@@ -1038,6 +1102,11 @@ function VideoBlock({
1038
1102
  style={urlInput}
1039
1103
  aria-label="Video source URL"
1040
1104
  />
1105
+ <MediaDurationField
1106
+ durationSeconds={durationSeconds}
1107
+ onChange={onDurationSecondsChange}
1108
+ hasSource={videoUrl.trim().length > 0}
1109
+ />
1041
1110
 
1042
1111
  <div style={videoActions}>
1043
1112
  <label className="btn sm" style={uploadButton}>
@@ -1072,7 +1141,9 @@ function VideoBlock({
1072
1141
 
1073
1142
  function AudioBlock({
1074
1143
  audioUrl,
1144
+ durationSeconds,
1075
1145
  onAudioUrlChange,
1146
+ onDurationSecondsChange,
1076
1147
  onAudioFileUpload,
1077
1148
  uploadStatus,
1078
1149
  uploadError,
@@ -1080,7 +1151,9 @@ function AudioBlock({
1080
1151
  pipelineError,
1081
1152
  }: {
1082
1153
  audioUrl: string
1154
+ durationSeconds: number | null
1083
1155
  onAudioUrlChange: (url: string) => void
1156
+ onDurationSecondsChange: (value: string) => void
1084
1157
  onAudioFileUpload: (file: File) => void | Promise<void>
1085
1158
  uploadStatus: 'idle' | 'uploading' | 'staged'
1086
1159
  uploadError: string | null
@@ -1169,6 +1242,11 @@ function AudioBlock({
1169
1242
  style={urlInput}
1170
1243
  aria-label="Audio R2 Key"
1171
1244
  />
1245
+ <MediaDurationField
1246
+ durationSeconds={durationSeconds}
1247
+ onChange={onDurationSecondsChange}
1248
+ hasSource={audioUrl.trim().length > 0}
1249
+ />
1172
1250
 
1173
1251
  <div style={assetHelp}>Only paste a key manually if the audio already exists in R2.</div>
1174
1252
 
@@ -1180,6 +1258,43 @@ function AudioBlock({
1180
1258
  )
1181
1259
  }
1182
1260
 
1261
+ function MediaDurationField({
1262
+ durationSeconds,
1263
+ onChange,
1264
+ hasSource,
1265
+ }: {
1266
+ durationSeconds: number | null
1267
+ onChange: (value: string) => void
1268
+ hasSource: boolean
1269
+ }) {
1270
+ const inputId = useId()
1271
+
1272
+ return (
1273
+ <>
1274
+ <label htmlFor={inputId} style={assetLabel}>
1275
+ Media duration (seconds)
1276
+ </label>
1277
+ <input
1278
+ id={inputId}
1279
+ type="number"
1280
+ min={1}
1281
+ step={1}
1282
+ value={durationSeconds ?? ''}
1283
+ onChange={(event) => onChange(event.currentTarget.value)}
1284
+ style={urlInput}
1285
+ aria-label="Media duration in seconds"
1286
+ />
1287
+ <div style={assetHelp}>
1288
+ Uploads are measured automatically and rounded up. For a pasted source URL or R2 key, enter
1289
+ its authoritative whole-second duration.
1290
+ </div>
1291
+ {hasSource && durationSeconds === null && (
1292
+ <div style={videoErrorText}>Duration is required before this source can be queued.</div>
1293
+ )}
1294
+ </>
1295
+ )
1296
+ }
1297
+
1183
1298
  const PODCAST_UPLOAD_HELP =
1184
1299
  'Large audio files can take a few minutes to upload; keep this tab open until the Audio R2 Key appears.'
1185
1300
 
@@ -1225,6 +1340,7 @@ function contentResponseToFormState(
1225
1340
  next.videoUrl = toStringValue(content.processing_source_url)
1226
1341
  next.videoSourceKind = toNullableString(content.processing_source_kind)
1227
1342
  next.videoId = toNullableString(content.video_id)
1343
+ next.durationSeconds = normalizeMediaDurationSeconds(content.duration_seconds)
1228
1344
  next.thumbnailImageId = thumbnailImageId
1229
1345
  next.thumbnailImageUrl =
1230
1346
  thumbnailImageUrl || (thumbnailImageId ? mediaUrlFromId(thumbnailImageId) : null)
@@ -1233,6 +1349,7 @@ function contentResponseToFormState(
1233
1349
  if (contentType === 'podcast') {
1234
1350
  next.body = toStringValue(content.transcript)
1235
1351
  next.audioUrl = toStringValue(content.audio_r2_key)
1352
+ next.durationSeconds = normalizeMediaDurationSeconds(content.duration_seconds)
1236
1353
  }
1237
1354
 
1238
1355
  return next
@@ -11,6 +11,12 @@ export interface ContentDraftFields {
11
11
  heroImageId?: string | null
12
12
  thumbnailImageId?: string | null
13
13
  audioUrl: string
14
+ durationSeconds: number | null
15
+ }
16
+
17
+ export function normalizeMediaDurationSeconds(value: unknown): number | null {
18
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
19
+ return Math.ceil(value)
14
20
  }
15
21
 
16
22
  export function slugifyTitle(title: string): string {
@@ -31,8 +37,18 @@ export function canCreateContentDraft(
31
37
  if (contentType === 'article' || contentType === 'newsletter' || contentType === 'page') {
32
38
  return draft.body.trim().length > 0
33
39
  }
34
- if (contentType === 'video') return draft.videoUrl.trim().length > 0
35
- if (contentType === 'podcast') return draft.audioUrl.trim().length > 0
40
+ if (contentType === 'video') {
41
+ return (
42
+ draft.videoUrl.trim().length > 0 &&
43
+ normalizeMediaDurationSeconds(draft.durationSeconds) !== null
44
+ )
45
+ }
46
+ if (contentType === 'podcast') {
47
+ return (
48
+ draft.audioUrl.trim().length > 0 &&
49
+ normalizeMediaDurationSeconds(draft.durationSeconds) !== null
50
+ )
51
+ }
36
52
  return false
37
53
  }
38
54
 
@@ -95,6 +111,7 @@ export function buildContentUpdatePayload(
95
111
  payload.content = {
96
112
  script: '',
97
113
  videoId,
114
+ durationSeconds: normalizeMediaDurationSeconds(draft.durationSeconds),
98
115
  ...(thumbnailImageId ? { thumbnailImageId } : {}),
99
116
  ...(sourceUrl
100
117
  ? {
@@ -107,6 +124,7 @@ export function buildContentUpdatePayload(
107
124
  payload.content = {
108
125
  transcript: '',
109
126
  audioR2Key: draft.audioUrl,
127
+ durationSeconds: normalizeMediaDurationSeconds(draft.durationSeconds),
110
128
  }
111
129
  }
112
130
 
@@ -0,0 +1,75 @@
1
+ export type EditorMediaKind = 'video' | 'audio'
2
+
3
+ export interface MediaMetadataElement {
4
+ preload: string
5
+ src: string
6
+ readonly duration: number
7
+ addEventListener(type: 'loadedmetadata' | 'error', listener: () => void): void
8
+ removeEventListener(type: 'loadedmetadata' | 'error', listener: () => void): void
9
+ load(): void
10
+ removeAttribute(name: 'src'): void
11
+ }
12
+
13
+ interface MediaDurationDependencies {
14
+ createMediaElement?: (kind: EditorMediaKind) => MediaMetadataElement
15
+ createObjectURL?: (file: File) => string
16
+ revokeObjectURL?: (url: string) => void
17
+ timeoutMs?: number
18
+ }
19
+
20
+ const DEFAULT_METADATA_TIMEOUT_MS = 15_000
21
+
22
+ /**
23
+ * Measure a selected local media file before upload. Fractional container
24
+ * durations are rounded up so the integer dispatch contract never understates
25
+ * the source runtime. No fallback estimate is permitted.
26
+ */
27
+ export async function measureEditorMediaDurationSeconds(
28
+ file: File,
29
+ kind: EditorMediaKind,
30
+ dependencies: MediaDurationDependencies = {},
31
+ ): Promise<number> {
32
+ const createMediaElement =
33
+ dependencies.createMediaElement ??
34
+ ((mediaKind: EditorMediaKind) => document.createElement(mediaKind) as MediaMetadataElement)
35
+ const createObjectURL =
36
+ dependencies.createObjectURL ?? ((value: File) => URL.createObjectURL(value))
37
+ const revokeObjectURL =
38
+ dependencies.revokeObjectURL ?? ((url: string) => URL.revokeObjectURL(url))
39
+ const timeoutMs = dependencies.timeoutMs ?? DEFAULT_METADATA_TIMEOUT_MS
40
+ const media = createMediaElement(kind)
41
+ const objectUrl = createObjectURL(file)
42
+ let timeout: ReturnType<typeof setTimeout> | null = null
43
+ let onLoadedMetadata: (() => void) | null = null
44
+ let onError: (() => void) | null = null
45
+
46
+ try {
47
+ return await new Promise<number>((resolve, reject) => {
48
+ onLoadedMetadata = () => {
49
+ const duration = media.duration
50
+ if (!Number.isFinite(duration) || duration <= 0) {
51
+ reject(new Error('Selected media must expose a positive finite duration'))
52
+ return
53
+ }
54
+ resolve(Math.ceil(duration))
55
+ }
56
+ onError = () => reject(new Error('Could not read duration metadata from selected media'))
57
+
58
+ media.addEventListener('loadedmetadata', onLoadedMetadata)
59
+ media.addEventListener('error', onError)
60
+ timeout = setTimeout(() => {
61
+ reject(new Error('Timed out reading duration metadata from selected media'))
62
+ }, timeoutMs)
63
+ media.preload = 'metadata'
64
+ media.src = objectUrl
65
+ media.load()
66
+ })
67
+ } finally {
68
+ if (timeout) clearTimeout(timeout)
69
+ if (onLoadedMetadata) media.removeEventListener('loadedmetadata', onLoadedMetadata)
70
+ if (onError) media.removeEventListener('error', onError)
71
+ media.removeAttribute('src')
72
+ media.load()
73
+ revokeObjectURL(objectUrl)
74
+ }
75
+ }
@@ -25,8 +25,9 @@
25
25
  // the auto-fill tile grid uses inline `display: grid` exactly as the prototype.
26
26
 
27
27
  import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
28
+ import { measureEditorMediaDurationSeconds } from '../editor/editor-media-duration.js'
28
29
  import { Icon, type IconName } from '../icons.js'
29
- import { type MediaUploadKind, type MediaUploadResult, uploadMediaAsset } from './media-upload.js'
30
+ import { type MediaUploadResult, uploadMediaAsset } from './media-upload.js'
30
31
 
31
32
  // ---------------------------------------------------------------------------
32
33
  // Kind filter — maps to the listLibrary `type` query param
@@ -146,7 +147,17 @@ export function MediaScreen() {
146
147
  setUploadError(null)
147
148
  setLastUpload(null)
148
149
  try {
149
- const result = await uploadMediaAsset({ kind: kind as MediaUploadKind, file })
150
+ const result =
151
+ kind === 'image'
152
+ ? await uploadMediaAsset({ kind, file })
153
+ : await uploadMediaAsset({
154
+ kind,
155
+ file,
156
+ durationSeconds: await measureEditorMediaDurationSeconds(
157
+ file,
158
+ kind === 'video' ? 'video' : 'audio',
159
+ ),
160
+ })
150
161
  setLastUpload(result)
151
162
  await fetchLibrary(kind)
152
163
  } catch (err) {
@@ -3,6 +3,7 @@ export type MediaUploadKind = 'image' | 'video' | 'podcast'
3
3
  export interface MediaUploadResult {
4
4
  id: string
5
5
  url: string | null
6
+ durationSeconds?: number
6
7
  sourceKind?: string | null
7
8
  processingStatus?: string | null
8
9
  videoId?: string | null
@@ -10,13 +11,22 @@ export interface MediaUploadResult {
10
11
 
11
12
  type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
12
13
 
13
- interface UploadMediaAssetInput {
14
- kind: MediaUploadKind
14
+ interface BaseUploadMediaAssetInput {
15
15
  file: File
16
16
  slug?: string
17
17
  fetchImpl?: FetchLike
18
18
  }
19
19
 
20
+ type UploadMediaAssetInput =
21
+ | (BaseUploadMediaAssetInput & {
22
+ kind: 'image'
23
+ durationSeconds?: never
24
+ })
25
+ | (BaseUploadMediaAssetInput & {
26
+ kind: 'video' | 'podcast'
27
+ durationSeconds: number
28
+ })
29
+
20
30
  interface VideoInitResponse {
21
31
  uploadId?: string
22
32
  key?: string
@@ -36,6 +46,7 @@ interface VideoCompleteResponse {
36
46
  sourceKind?: string
37
47
  processingStatus?: string
38
48
  videoId?: string
49
+ durationSeconds?: number
39
50
  }
40
51
 
41
52
  interface AudioInitResponse {
@@ -54,6 +65,20 @@ interface AudioPartResponse {
54
65
  interface AudioCompleteResponse {
55
66
  key?: string
56
67
  publicUrl?: string
68
+ durationSeconds?: number
69
+ }
70
+
71
+ function requirePositiveIntegerDuration(input: { durationSeconds?: unknown }): number {
72
+ const durationSeconds = input.durationSeconds
73
+ if (
74
+ typeof durationSeconds !== 'number' ||
75
+ !Number.isFinite(durationSeconds) ||
76
+ !Number.isInteger(durationSeconds) ||
77
+ durationSeconds <= 0
78
+ ) {
79
+ throw new Error('Media upload requires a positive integer duration')
80
+ }
81
+ return durationSeconds
57
82
  }
58
83
 
59
84
  export function buildMediaUploadSlug(fileName: string): string {
@@ -118,6 +143,7 @@ export async function uploadMediaAsset(input: UploadMediaAssetInput): Promise<Me
118
143
  }
119
144
 
120
145
  if (input.kind === 'podcast') {
146
+ const durationSeconds = requirePositiveIntegerDuration(input)
121
147
  if (input.file.size <= 0) {
122
148
  throw new Error('Podcast audio file is empty')
123
149
  }
@@ -133,6 +159,7 @@ export async function uploadMediaAsset(input: UploadMediaAssetInput): Promise<Me
133
159
  fileName: input.file.name,
134
160
  contentType: input.file.type || null,
135
161
  sizeBytes: input.file.size,
162
+ durationSeconds,
136
163
  }),
137
164
  })
138
165
  const init = await assertOk<AudioInitResponse>(
@@ -177,6 +204,7 @@ export async function uploadMediaAsset(input: UploadMediaAssetInput): Promise<Me
177
204
  fileName: input.file.name,
178
205
  contentType: input.file.type || null,
179
206
  sizeBytes: input.file.size,
207
+ durationSeconds,
180
208
  parts,
181
209
  }),
182
210
  })
@@ -184,9 +212,13 @@ export async function uploadMediaAsset(input: UploadMediaAssetInput): Promise<Me
184
212
  completeResponse,
185
213
  'Could not complete podcast audio upload',
186
214
  )
215
+ if (complete.durationSeconds !== durationSeconds) {
216
+ throw new Error('Podcast upload did not preserve the measured duration')
217
+ }
187
218
  return {
188
219
  id: complete.key || key,
189
220
  url: complete.publicUrl || null,
221
+ durationSeconds,
190
222
  }
191
223
  } catch (error) {
192
224
  if (uploadId && key) {
@@ -200,6 +232,7 @@ export async function uploadMediaAsset(input: UploadMediaAssetInput): Promise<Me
200
232
  }
201
233
  }
202
234
 
235
+ const durationSeconds = requirePositiveIntegerDuration(input)
203
236
  if (input.file.size <= 0) {
204
237
  throw new Error('Video file is empty')
205
238
  }
@@ -215,6 +248,7 @@ export async function uploadMediaAsset(input: UploadMediaAssetInput): Promise<Me
215
248
  fileName: input.file.name,
216
249
  contentType: input.file.type || null,
217
250
  sizeBytes: input.file.size,
251
+ durationSeconds,
218
252
  }),
219
253
  })
220
254
  const init = await assertOk<VideoInitResponse>(initResponse, 'Could not start video upload')
@@ -256,6 +290,7 @@ export async function uploadMediaAsset(input: UploadMediaAssetInput): Promise<Me
256
290
  fileName: input.file.name,
257
291
  contentType: input.file.type || null,
258
292
  sizeBytes: input.file.size,
293
+ durationSeconds,
259
294
  parts,
260
295
  }),
261
296
  })
@@ -263,12 +298,16 @@ export async function uploadMediaAsset(input: UploadMediaAssetInput): Promise<Me
263
298
  completeResponse,
264
299
  'Could not complete video upload',
265
300
  )
301
+ if (complete.durationSeconds !== durationSeconds) {
302
+ throw new Error('Video upload did not preserve the measured duration')
303
+ }
266
304
  return {
267
305
  id: complete.key || key,
268
306
  url: complete.sourceUrl || null,
269
307
  sourceKind: complete.sourceKind || 'http_url',
270
308
  processingStatus: complete.processingStatus || 'processing',
271
309
  videoId: complete.videoId || slug,
310
+ durationSeconds,
272
311
  }
273
312
  } catch (error) {
274
313
  if (uploadId && key) {