diff --git a/js/nostr/nip71.js b/js/nostr/nip71.js index 069e8b43..44d74579 100644 --- a/js/nostr/nip71.js +++ b/js/nostr/nip71.js @@ -1520,6 +1520,15 @@ export function convertEventToVideo(event = {}) { .filter((value) => typeof value === "string" && value.trim()) .map((value) => value.trim()) : []; + const toPositiveInt = (value) => { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined; + }; + const width = toPositiveInt(parsedContent.width); + const height = toPositiveInt(parsedContent.height); + const durationValue = Number(parsedContent.duration); + const duration = + Number.isFinite(durationValue) && durationValue > 0 ? durationValue : undefined; const videoRootId = safeTrim(parsedContent.videoRootId) || event.id; const wsField = safeTrim(parsedContent.ws); const xsField = safeTrim(parsedContent.xs); @@ -1624,6 +1633,9 @@ export function convertEventToVideo(event = {}) { mode, deleted, hashtags, + width, + height, + duration, ws, xs, storagePointer, diff --git a/js/nostr/videoPayloadBuilder.js b/js/nostr/videoPayloadBuilder.js index db3da3af..9410feed 100644 --- a/js/nostr/videoPayloadBuilder.js +++ b/js/nostr/videoPayloadBuilder.js @@ -8,6 +8,18 @@ import { inferMimeTypeFromUrl } from "../utils/mime.js"; import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { devLogger } from "../utils/logger.js"; +// Pixel dimensions: positive integer or 0 (= "unknown / omit"). +export function normalizeVideoDimension(value) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; +} + +// Duration in seconds: positive (float ok) or 0 (= omit). +export function normalizeVideoDuration(value) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : 0; +} + // Normalize free-form hashtag input (array or comma/space string) to a deduped, // lowercased list. Drops the reserved "video" topic tag and caps the count. export function sanitizeVideoHashtags(input) { @@ -191,6 +203,21 @@ export async function prepareVideoPublishPayload(videoPayload, pubkey, { timesta contentObject.hashtags = hashtags; } + // Captured-at-upload dimensions/duration: persist so a video's orientation is + // known durably (drives 34236 short selection + a future "shorts" feed filter). + const width = normalizeVideoDimension(videoData.width); + const height = normalizeVideoDimension(videoData.height); + const duration = normalizeVideoDuration(videoData.duration); + if (width) { + contentObject.width = width; + } + if (height) { + contentObject.height = height; + } + if (duration) { + contentObject.duration = duration; + } + const nip71Tags = buildNip71MetadataTags( nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null ); @@ -484,6 +511,20 @@ export function prepareVideoEditPayload(params) { } const hashtagTags = hashtags.map((tag) => ["t", tag]); + // Preserve captured dimensions/duration across edits (prefer edited values). + const width = normalizeVideoDimension(updatedData?.width ?? baseEvent.width); + const height = normalizeVideoDimension(updatedData?.height ?? baseEvent.height); + const duration = normalizeVideoDuration(updatedData?.duration ?? baseEvent.duration); + if (width) { + contentObject.width = width; + } + if (height) { + contentObject.height = height; + } + if (duration) { + contentObject.duration = duration; + } + let metadataForTags = nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null; if (!metadataForTags) { diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index f4344088..fa618431 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -1,6 +1,7 @@ // components/UploadModal.js import { createModalAccessibility } from "./modalAccessibility.js"; import { Nip71FormManager } from "./nip71FormManager.js"; +import { probeVideoMetadata } from "../../utils/videoProbe.js"; import { MediaUploader } from "./mediaUploader.js"; import { devLogger, userLogger } from "../../utils/logger.js"; import { @@ -483,10 +484,35 @@ export class UploadModal { // --- Immediate Upload Handlers --- + // Best-effort: read the file's dimensions/duration so we can persist them + // (orientation → 34236 short selection + a future "shorts" feed). Never blocks + // upload; failures just leave the metadata unset. + async captureVideoMetadata(file) { + this.capturedMetadata = null; + if (!file || typeof URL === "undefined" || typeof URL.createObjectURL !== "function") { + return; + } + const objectUrl = URL.createObjectURL(file); + try { + this.capturedMetadata = await probeVideoMetadata(objectUrl); + } catch (error) { + this.capturedMetadata = null; + } finally { + try { + URL.revokeObjectURL(objectUrl); + } catch (revokeError) { + // ignore + } + } + } + async handleVideoSelection(e) { const file = e.target.files?.[0]; if (!file) return; + // Probe dimensions/duration in the background while the upload proceeds. + void this.captureVideoMetadata(file); + if (!this.storageConfigured) { alert("Please configure storage before selecting a file."); e.target.value = ""; // Clear selection @@ -900,6 +926,12 @@ export class UploadModal { enableComments: this.toggles.comments?.checked || true, ...audienceFlags, + // Auto-captured at file-select (best-effort; absent ⇒ omitted). Drives + // 34236 short selection + a future "shorts" feed (height > width). + width: this.capturedMetadata?.width || 0, + height: this.capturedMetadata?.height || 0, + duration: this.capturedMetadata?.duration || 0, + // These might be empty depending on mode, filled below url: "", magnet: "", diff --git a/js/utils/videoProbe.js b/js/utils/videoProbe.js new file mode 100644 index 00000000..0fdc6bbc --- /dev/null +++ b/js/utils/videoProbe.js @@ -0,0 +1,80 @@ +// Auto-capture a video's dimensions + duration by loading just its metadata into +// a hidden