@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.
- package/README.md +30 -3
- package/dist/engine/foundry-dispatch.d.ts +5 -1
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +19 -3
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/published-content.d.ts.map +1 -1
- package/dist/engine/published-content.js +29 -12
- package/dist/engine/published-content.js.map +1 -1
- package/dist/engine/publisher.d.ts +4 -0
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +190 -54
- package/dist/engine/publisher.js.map +1 -1
- package/dist/providers/types.d.ts +1 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +76 -16
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/context.d.ts +1 -0
- package/dist/routes/context.d.ts.map +1 -1
- package/dist/routes/context.js.map +1 -1
- package/dist/routes/media.d.ts.map +1 -1
- package/dist/routes/media.js +53 -31
- package/dist/routes/media.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +65 -10
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/content-payload.d.ts +2 -0
- package/dist/ui/editor/content-payload.d.ts.map +1 -1
- package/dist/ui/editor/content-payload.js +15 -4
- package/dist/ui/editor/content-payload.js.map +1 -1
- package/dist/ui/editor/editor-media-duration.d.ts +24 -0
- package/dist/ui/editor/editor-media-duration.d.ts.map +1 -0
- package/dist/ui/editor/editor-media-duration.js +51 -0
- package/dist/ui/editor/editor-media-duration.js.map +1 -0
- package/dist/ui/screens/MediaScreen.d.ts.map +1 -1
- package/dist/ui/screens/MediaScreen.js +8 -1
- package/dist/ui/screens/MediaScreen.js.map +1 -1
- package/dist/ui/screens/media-upload.d.ts +9 -2
- package/dist/ui/screens/media-upload.d.ts.map +1 -1
- package/dist/ui/screens/media-upload.js +24 -0
- package/dist/ui/screens/media-upload.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/foundry-dispatch.ts +32 -4
- package/src/engine/index.ts +1 -0
- package/src/engine/published-content.ts +32 -16
- package/src/engine/publisher.ts +297 -97
- package/src/providers/types.ts +1 -0
- package/src/routes/content.ts +91 -16
- package/src/routes/context.ts +1 -0
- package/src/routes/media.ts +63 -41
- package/src/ui/editor/ContentForm.tsx +121 -4
- package/src/ui/editor/content-payload.ts +20 -2
- package/src/ui/editor/editor-media-duration.ts +75 -0
- package/src/ui/screens/MediaScreen.tsx +13 -2
- 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
|
|
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:
|
|
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 = {
|
|
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')
|
|
35
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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) {
|