From cee9aa478fa2c35ac036600570ae11acffbc4709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Mon, 6 Oct 2025 16:42:58 +0200 Subject: [PATCH] Videos, NIP-71 --- src/Controller/AuthorController.php | 30 +++-- src/Service/NostrClient.php | 60 +++++++++ templates/event/_kind22_video.html.twig | 162 ++++++++++++++++++++++++ templates/event/index.html.twig | 7 +- templates/pages/author-media.html.twig | 64 +++++++++- tests/NIPs/NIP-71.feature | 157 +++++++++++++++++++++++ 6 files changed, 466 insertions(+), 14 deletions(-) create mode 100644 templates/event/_kind22_video.html.twig create mode 100644 tests/NIPs/NIP-71.feature diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php index f7d51da..f7efd36 100644 --- a/src/Controller/AuthorController.php +++ b/src/Controller/AuthorController.php @@ -23,9 +23,6 @@ class AuthorController extends AbstractController #[Route('/p/{npub}/media', name: 'author-media', requirements: ['npub' => '^npub1.*'])] public function media($npub, NostrClient $nostrClient, RedisCacheService $redisCacheService): Response { - $keys = new Key(); - $pubkey = $keys->convertToHex($npub); - $author = $redisCacheService->getMetadata($npub); // Retrieve picture events (kind 20) for the author @@ -35,22 +32,39 @@ class AuthorController extends AbstractController $pictureEvents = []; } + // Retrieve video shorts (kind 22) for the author + try { + $videoShorts = $nostrClient->getVideoShortsForPubkey($npub, 30); + } catch (Exception $e) { + $videoShorts = []; + } + + // Retrieve normal videos (kind 21) for the author + try { + $normalVideos = $nostrClient->getNormalVideosForPubkey($npub, 30); + } catch (Exception $e) { + $normalVideos = []; + } + + // Merge picture events, video shorts, and normal videos + $mediaEvents = array_merge($pictureEvents, $videoShorts, $normalVideos); + // Deduplicate by event ID $uniqueEvents = []; - foreach ($pictureEvents as $event) { + foreach ($mediaEvents as $event) { if (!isset($uniqueEvents[$event->id])) { $uniqueEvents[$event->id] = $event; } } // Convert back to indexed array and sort by date (newest first) - $pictureEvents = array_values($uniqueEvents); - usort($pictureEvents, function ($a, $b) { + $mediaEvents = array_values($uniqueEvents); + usort($mediaEvents, function ($a, $b) { return $b->created_at <=> $a->created_at; }); // Encode event IDs as note1... for each event - foreach ($pictureEvents as $event) { + foreach ($mediaEvents as $event) { $nip19 = new Nip19Helper(); // The NIP-19 helper class. $event->noteId = $nip19->encodeNote($event->id); } @@ -58,7 +72,7 @@ class AuthorController extends AbstractController return $this->render('pages/author-media.html.twig', [ 'author' => $author, 'npub' => $npub, - 'pictureEvents' => $pictureEvents, + 'pictureEvents' => $mediaEvents, 'is_author_profile' => true, ]); } diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 155fd64..ee07782 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -546,6 +546,66 @@ class NostrClient }); } + /** + * Get video shorts (kind 22) for a given pubkey + * @throws \Exception + */ + public function getVideoShortsForPubkey(string $ident, int $limit = 20): array + { + // Add user relays to the default set + $authorRelays = $this->getTopReputableRelaysForAuthor($ident); + // Create a RelaySet from the author's relays + $relaySet = $this->defaultRelaySet; + if (!empty($authorRelays)) { + $relaySet = $this->createRelaySet($authorRelays); + } + + // Create request for kind 22 (short video events) + $request = $this->createNostrRequest( + kinds: [22], // NIP-71 Short video events + filters: [ + 'authors' => [$ident], + 'limit' => $limit + ], + relaySet: $relaySet + ); + + // Process the response and return raw events + return $this->processResponse($request->send(), function($event) { + return $event; // Return the raw event + }); + } + + /** + * Get normal video events (kind 21) for a given pubkey + * @throws \Exception + */ + public function getNormalVideosForPubkey(string $ident, int $limit = 20): array + { + // Add user relays to the default set + $authorRelays = $this->getTopReputableRelaysForAuthor($ident); + // Create a RelaySet from the author's relays + $relaySet = $this->defaultRelaySet; + if (!empty($authorRelays)) { + $relaySet = $this->createRelaySet($authorRelays); + } + + // Create request for kind 21 (normal video events) + $request = $this->createNostrRequest( + kinds: [21], // NIP-71 Normal video events + filters: [ + 'authors' => [$ident], + 'limit' => $limit + ], + relaySet: $relaySet + ); + + // Process the response and return raw events + return $this->processResponse($request->send(), function($event) { + return $event; // Return the raw event + }); + } + public function getArticles(array $slugs): array { $articles = []; diff --git a/templates/event/_kind22_video.html.twig b/templates/event/_kind22_video.html.twig new file mode 100644 index 0000000..1465aa2 --- /dev/null +++ b/templates/event/_kind22_video.html.twig @@ -0,0 +1,162 @@ +{# NIP-71 Video Event (kind 22) #} +
+ {# Title tag #} + {% set title = null %} + {% for tag in event.tags %} + {% if tag[0] == 'title' %} + {% set title = tag[1] %} + {% endif %} + {% endfor %} + + {% if title %} +

{{ title }}

+ {% endif %} + + {# Content warning #} + {% set contentWarning = null %} + {% for tag in event.tags %} + {% if tag[0] == 'content-warning' %} + {% set contentWarning = tag[1] %} + {% endif %} + {% endfor %} + + {% if contentWarning %} +
+ ⚠️ Content Warning: {{ contentWarning }} + +
+ diff --git a/templates/event/index.html.twig b/templates/event/index.html.twig index 61e1455..8532645 100644 --- a/templates/event/index.html.twig +++ b/templates/event/index.html.twig @@ -22,8 +22,11 @@ {# NIP-68 Picture Event (kind 20) #} {% if event.kind == 20 %} {% include 'event/_kind20_picture.html.twig' %} + {# NIP-71 Video Events (kind 21 and 22) #} + {% elseif event.kind == 21 or event.kind == 22 %} + {% include 'event/_kind22_video.html.twig' %} {% else %} - {# Regular event content for non-picture events #} + {# Regular event content for non-picture and non-video events #}
@@ -47,7 +50,7 @@ {# Source link from r tag #} {% set sourceUrl = null %} {% for tag in event.tags %} - {% if tag[0] == 'r' and tag[2] == 'source' %} + {% if tag[0] == 'r' and tag|length > 2 and tag[2] == 'source' %} {% set sourceUrl = tag[1] %} {% endif %} {% endfor %} diff --git a/templates/pages/author-media.html.twig b/templates/pages/author-media.html.twig index c6b7e2e..1503186 100644 --- a/templates/pages/author-media.html.twig +++ b/templates/pages/author-media.html.twig @@ -50,17 +50,38 @@ {# Extract first image from imeta tags #} {% set firstImageUrl = null %} + {% set firstVideoUrl = null %} {% set imageAlt = null %} + {% set isVideo = false %} {% for tag in event.tags %} - {% if tag[0] == 'imeta' and firstImageUrl is null %} + {% if tag[0] == 'imeta' %} + {% set videoUrl = null %} + {% set imageUrl = null %} {% for i in 1..(tag|length - 1) %} {% set param = tag[i] %} - {% if param starts with 'url ' and firstImageUrl is null %} - {% set firstImageUrl = param[4:] %} + {% if param starts with 'url ' %} + {% set potentialUrl = param[4:] %} + {# Check if it's a video URL #} + {% if potentialUrl matches '/\\.(mp4|webm|ogg|mov)$/i' or potentialUrl matches '/video/i' %} + {% set videoUrl = potentialUrl %} + {% set isVideo = true %} + {% else %} + {% set imageUrl = potentialUrl %} + {% endif %} + {% elseif param starts with 'image ' %} + {# Preview image for video #} + {% set imageUrl = param[6:] %} {% elseif param starts with 'alt ' %} {% set imageAlt = param[4:] %} {% endif %} {% endfor %} + {# Set the first video and image found #} + {% if videoUrl and firstVideoUrl is null %} + {% set firstVideoUrl = videoUrl %} + {% endif %} + {% if imageUrl and firstImageUrl is null %} + {% set firstImageUrl = imageUrl %} + {% endif %} {% endif %} {% endfor %} @@ -72,9 +93,16 @@ {% if firstImageUrl %}
{{ imageAlt|default(title|default('Picture')) }} + {% if isVideo %} +
+ + + +
+ {% endif %}
{% endif %} @@ -104,3 +132,31 @@ {% endif %}
{% endblock %} + +{% block stylesheets %} +{{ parent() }} + +{% endblock %} diff --git a/tests/NIPs/NIP-71.feature b/tests/NIPs/NIP-71.feature new file mode 100644 index 0000000..ffbdd34 --- /dev/null +++ b/tests/NIPs/NIP-71.feature @@ -0,0 +1,157 @@ +Feature: NIP-71 Video Events + As a nostr application developer + I want to create video content events + So that videos can contribute to the multi-media experience + + Background: + Given I have a valid nostr keypair + And I am connected to a relay + + Scenario: Creating a basic normal video event + Given I create a video event with kind 21 + And I set a title tag with "My First Video" + And I add a description in the content + And I include an imeta tag with video URL "https://myvideo.com/1080/12345.mp4" + And I set the video mime type to "video/mp4" + And I set the video dimensions to "1920x1080" + And I include a preview image "https://myvideo.com/1080/12345.jpg" + When I publish the video event + Then the event should be stored and retrievable + And the video should be self-contained with external hosting + + Scenario: Creating a short-form video event + Given I create a video event with kind 22 + And I set a title tag with "My Short Story" + And I add a description in the content + And I include an imeta tag with video URL "https://myvideo.com/shorts/67890.mp4" + And I set the video mime type to "video/mp4" + And I set the video dimensions to "1080x1920" + When I publish the video event + Then the event should be stored as a short-form video + And clients should display it in a vertical format + + Scenario: Video event with multiple quality variants + Given I create a video event with kind 21 + And I set a title tag + And I include an imeta tag for 1920x1080 resolution with URL "https://myvideo.com/1080/12345.mp4" + And I include an imeta tag for 1280x720 resolution with URL "https://myvideo.com/720/12345.mp4" + And I include an imeta tag for 1280x720 HLS stream with URL "https://myvideo.com/720/12345.m3u8" + And each variant has a SHA-256 hash using "x" parameter + When I publish the video event + Then all video variants should be available + And each variant should have its own imeta tag with dimensions + + Scenario: Video with complete metadata + Given I create a video event with kind 21 + And I include an imeta tag with URL, mime type, dimensions, and SHA-256 hash + And I add a preview image for the video + And I add an alt text for accessibility + And I provide fallback URLs + And I include "service nip96" in the imeta tag + And I set a duration tag with "300" seconds + And I set a published_at timestamp + When I publish the video event + Then the video metadata should be complete and queryable + + Scenario: Video with fallback servers + Given I create a video event with kind 21 + And I set the primary video URL to "https://myvideo.com/1080/12345.mp4" + And I add fallback URLs "https://myotherserver.com/1080/12345.mp4" + And I add fallback URLs "https://andanotherserver.com/1080/12345.mp4" + And I add preview image fallbacks + When I publish the video event + Then clients should be able to use any of the provided URLs equally + + Scenario: Tagging participants in videos + Given I create a video event with kind 21 + And I include p tags for multiple participants + And I add recommended relay URLs for each participant + When I publish the video event + Then tagged participants should be linked to the video + And users should be notified of being tagged + + Scenario: NSFW content warning for video + Given I create a video event with sensitive content + And I add a content-warning tag with reason + When I publish the video event + Then clients should display a content warning before showing the video + + Scenario: Video with hashtags + Given I create a video event with kind 21 + And I add multiple t tags for hashtags + When I publish the video event + Then the video should be discoverable by hashtags + + Scenario: Video with text tracks (captions/subtitles) + Given I create a video event with kind 21 + And I add a text-track tag linking to WebVTT file "https://myvideo.com/captions/en.vtt" + And I specify the track type as "captions" + And I specify the language code as "en" + When I publish the video event + Then the video should support captions and subtitles + + Scenario: Video with chapter segments + Given I create a video event with kind 21 + And I add a segment tag with start "00:00:00.000", end "00:05:30.000", title "Introduction" + And I add a segment tag with start "00:05:30.000", end "00:15:45.000", title "Main Content" + And I include thumbnail URLs for each segment + When I publish the video event + Then the video should have navigable chapters + + Scenario: Video with reference links + Given I create a video event with kind 21 + And I add multiple r tags with reference URLs + When I publish the video event + Then the reference links should be associated with the video + + Scenario: Supported video formats + Given I create a video event with kind 21 + When I try to use a video with mime type "" + Then the event should accept valid video types + Examples: + | mime_type | + | video/mp4 | + | video/webm | + | video/ogg | + | video/quicktime | + | application/x-mpegURL | + + Scenario: Video with NIP-96 service integration + Given I create a video event with kind 21 + And I include "service nip96" in the imeta tag + And I include SHA-256 hash for the video + When I publish the video event + Then clients should be able to search the author's NIP-96 server list + And the file should be findable using the hash + + Scenario: Queryable video hashes + Given I create a video event with multiple variants + And I include x tags with SHA-256 hashes for each variant + When I publish the video event + Then the videos should be queryable by their hashes + + Scenario: Video with published timestamp + Given I create a video event with kind 21 + And I set a published_at tag with the first publication timestamp + When I publish the video event + Then the original publication time should be preserved + And it should differ from the created_at timestamp if republished + + Scenario: Complete video event structure + Given I create a video event with kind 21 + And I set a title tag with "Complete Tutorial Video" + And I add a summary in the content field + And I set a published_at timestamp + And I add an alt text description + And I include imeta tags for multiple quality variants + And I set a duration tag + And I add text-track tags for captions + And I add a content-warning if needed + And I add segment tags for chapters + And I include p tags for participants + And I add t tags for hashtags + And I add r tags for reference links + When I publish the video event + Then all metadata should be properly structured + And the event should be fully compatible with video-specific clients +