Browse Source

Videos, NIP-71

imwald
Nuša Pukšič 3 months ago
parent
commit
cee9aa478f
  1. 30
      src/Controller/AuthorController.php
  2. 60
      src/Service/NostrClient.php
  3. 162
      templates/event/_kind22_video.html.twig
  4. 7
      templates/event/index.html.twig
  5. 64
      templates/pages/author-media.html.twig
  6. 157
      tests/NIPs/NIP-71.feature

30
src/Controller/AuthorController.php

@ -23,9 +23,6 @@ class AuthorController extends AbstractController @@ -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 @@ -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 @@ -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,
]);
}

60
src/Service/NostrClient.php

@ -546,6 +546,66 @@ class NostrClient @@ -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 = [];

162
templates/event/_kind22_video.html.twig

@ -0,0 +1,162 @@ @@ -0,0 +1,162 @@
{# NIP-71 Video Event (kind 22) #}
<div class="video-event">
{# Title tag #}
{% set title = null %}
{% for tag in event.tags %}
{% if tag[0] == 'title' %}
{% set title = tag[1] %}
{% endif %}
{% endfor %}
{% if title %}
<h2 class="video-title">{{ title }}</h2>
{% endif %}
{# Content warning #}
{% set contentWarning = null %}
{% for tag in event.tags %}
{% if tag[0] == 'content-warning' %}
{% set contentWarning = tag[1] %}
{% endif %}
{% endfor %}
{% if contentWarning %}
<div class="content-warning">
<strong>⚠ Content Warning:</strong> {{ contentWarning }}
<button class="btn-show-nsfw" onclick="this.parentElement.nextElementSibling.classList.remove('hidden'); this.parentElement.style.display='none';">Show Content</button>
</div>
<div class="video-gallery hidden">
{% else %}
<div class="video-gallery">
{% endif %}
{# Display videos from imeta tags #}
{% for tag in event.tags %}
{% if tag[0] == 'imeta' %}
{% set videoUrl = null %}
{% set mimeType = null %}
{% set dimensions = null %}
{% set altText = null %}
{% set previewImage = null %}
{% set fallbacks = [] %}
{# Parse imeta tag parameters #}
{% for i in 1..(tag|length - 1) %}
{% set param = tag[i] %}
{% 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 %}
{% endif %}
{% elseif param starts with 'image ' %}
{% set previewImage = param[6:] %}
{% elseif param starts with 'm ' %}
{% set mimeType = param[2:] %}
{% elseif param starts with 'dim ' %}
{% set dimensions = param[4:] %}
{% elseif param starts with 'alt ' %}
{% set altText = param[4:] %}
{% elseif param starts with 'fallback ' %}
{% set fallbackUrl = param[9:] %}
{# Only add video fallbacks #}
{% if fallbackUrl matches '/\\.(mp4|webm|ogg|mov)$/i' or fallbackUrl matches '/video/i' %}
{% set fallbacks = fallbacks|merge([fallbackUrl]) %}
{% endif %}
{% endif %}
{% endfor %}
{% if videoUrl %}
<div class="video-item">
<video controls
{% if previewImage %}poster="{{ previewImage }}"{% endif %}
{% if dimensions %}data-dimensions="{{ dimensions }}"{% endif %}
class="video-player"
preload="metadata">
<source src="{{ videoUrl }}" {% if mimeType %}type="{{ mimeType }}"{% endif %} />
{% for fallback in fallbacks %}
<source src="{{ fallback }}" />
{% endfor %}
Your browser does not support the video tag.
</video>
{% if altText %}
<p class="video-alt">{{ altText }}</p>
{% endif %}
</div>
{% endif %}
{% endif %}
{% endfor %}
</div>
{# Description from content #}
{% if event.content %}
<div class="video-description mt-1">
<twig:Atoms:Content :content="event.content" />
</div>
{% endif %}
{# Duration #}
{% set duration = null %}
{% for tag in event.tags %}
{% if tag[0] == 'duration' %}
{% set duration = tag[1] %}
{% endif %}
{% endfor %}
{% if duration %}
<div class="video-duration">
<span class="duration-icon">⏱</span>
<span>Duration: {{ (duration / 60)|round(0, 'floor') }}:{{ '%02d'|format(duration % 60) }}</span>
</div>
{% endif %}
{# Published timestamp #}
{% set publishedAt = null %}
{% for tag in event.tags %}
{% if tag[0] == 'published_at' %}
{% set publishedAt = tag[1] %}
{% endif %}
{% endfor %}
{% if publishedAt %}
<div class="video-published">
<span>Originally published: {{ publishedAt|date('F j, Y') }}</span>
</div>
{% endif %}
{# Hashtags #}
{% set hashtags = [] %}
{% for tag in event.tags %}
{% if tag[0] == 't' %}
{% set hashtags = hashtags|merge([tag[1]]) %}
{% endif %}
{% endfor %}
{% if hashtags|length > 0 %}
<div class="video-hashtags">
{% for hashtag in hashtags %}
<span class="hashtag">#{{ hashtag }}</span>
{% endfor %}
</div>
{% endif %}
{# Participants #}
{% set participants = [] %}
{% for tag in event.tags %}
{% if tag[0] == 'p' %}
{% set participants = participants|merge([tag[1]]) %}
{% endif %}
{% endfor %}
{% if participants|length > 0 %}
<div class="video-participants">
<h4>Participants:</h4>
<div class="participants-list">
{% for pubkey in participants %}
<twig:Molecules:UserFromNpub ident="{{ pubkey }}" />
{% endfor %}
</div>
</div>
{% endif %}
</div>

7
templates/event/index.html.twig

@ -22,8 +22,11 @@ @@ -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 #}
<div class="event-content">
<twig:Atoms:Content :content="event.content" />
</div>
@ -47,7 +50,7 @@ @@ -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 %}

64
templates/pages/author-media.html.twig

@ -50,17 +50,38 @@ @@ -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 @@ @@ -72,9 +93,16 @@
{% if firstImageUrl %}
<div class="masonry-image-container">
<img src="{{ firstImageUrl }}"
alt="{{ imageAlt|default(title|default('Picture')) }}"
alt="{{ imageAlt|default(title|default(isVideo ? 'Video' : 'Picture')) }}"
class="masonry-image"
loading="lazy" />
{% if isVideo %}
<div class="video-overlay">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="white" opacity="0.9">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
{% endif %}
</div>
{% endif %}
@ -104,3 +132,31 @@ @@ -104,3 +132,31 @@
{% endif %}
</div>
{% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
.masonry-image-container {
position: relative;
}
.video-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
}
.video-overlay svg {
margin-left: 4px;
}
</style>
{% endblock %}

157
tests/NIPs/NIP-71.feature

@ -0,0 +1,157 @@ @@ -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 "<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
Loading…
Cancel
Save