diff --git a/extensions/PickiPediaReleases/resources/ext.pickipediaReleases.releaseDraft.css b/extensions/PickiPediaReleases/resources/ext.pickipediaReleases.releaseDraft.css index 0505b0d..70c86b5 100644 --- a/extensions/PickiPediaReleases/resources/ext.pickipediaReleases.releaseDraft.css +++ b/extensions/PickiPediaReleases/resources/ext.pickipediaReleases.releaseDraft.css @@ -265,6 +265,85 @@ border-radius: 4px; } +/* Trim controls */ +.rd-trim-controls { + margin: 0.75em 0 1.5em; + max-width: 800px; +} + +.rd-trim-controls h4 { + margin: 0 0 0.5em; + font-size: 0.95em; +} + +.rd-trim-row { + display: flex; + gap: 1.5em; + flex-wrap: wrap; +} + +.rd-trim-field { + display: flex; + align-items: center; + gap: 0.5em; +} + +.rd-trim-field label { + font-weight: bold; + font-size: 0.9em; + color: #54595d; +} + +.rd-trim-input { + width: 5em; + text-align: center; + font-family: monospace; +} + +.rd-trim-set-btn { + font-size: 0.85em; + padding: 0.25em 0.6em; + cursor: pointer; +} + +.rd-trim-preview { + margin-top: 0.5em; + font-size: 0.85em; + color: #54595d; + font-style: italic; +} + +/* Preview status */ +.rd-preview-status { + margin: 0.75em 0; + padding: 0.5em 0.75em; + font-size: 0.9em; + color: #54595d; + background: #f8f9fa; + border-radius: 3px; +} + +/* Preserve original checkbox */ +.rd-preserve-label { + display: flex; + align-items: center; + gap: 0.4em; + font-size: 0.9em; + color: #54595d; + cursor: pointer; +} + +/* More settings (collapsed) */ +.rd-more-settings { + margin: 1em 0; + font-size: 0.9em; +} + +.rd-more-settings summary { + cursor: pointer; + color: #72777d; +} + /* Raw YAML toggle */ .release-raw-yaml { margin-top: 2em; diff --git a/extensions/PickiPediaReleases/resources/ext.pickipediaReleases.releaseDraft.js b/extensions/PickiPediaReleases/resources/ext.pickipediaReleases.releaseDraft.js index eddea43..671fa3a 100644 --- a/extensions/PickiPediaReleases/resources/ext.pickipediaReleases.releaseDraft.js +++ b/extensions/PickiPediaReleases/resources/ext.pickipediaReleases.releaseDraft.js @@ -209,6 +209,20 @@ } } + // Trim points (shared across video and content forms) + var trimStartEl = el( 'rd-trim-start' ); + var trimEndEl = el( 'rd-trim-end' ); + if ( trimStartEl && trimStartEl.value.trim() ) { + data.content.trim_start_seconds = parseFloat( trimStartEl.value ); + } else { + delete data.content.trim_start_seconds; + } + if ( trimEndEl && trimEndEl.value.trim() ) { + data.content.trim_end_seconds = parseFloat( trimEndEl.value ); + } else { + delete data.content.trim_end_seconds; + } + // Blockheight (content time — user-editable) var blockheightEl = el( 'rd-blockheight' ); if ( blockheightEl && blockheightEl.value.trim() ) { @@ -569,14 +583,25 @@ } endpoint = '/draft-content/' + draftId + '/finalize'; - body = JSON.stringify( { + var finalizeBody = { title: content.title || null, description: content.description || null, file_type: content.file_type || null, subsequent_to: content.subsequent_to || null, transcoding_strategy: 'auto', metadata: {} - } ); + }; + if ( content.trim_start_seconds != null ) { + finalizeBody.trim_start_seconds = content.trim_start_seconds; + } + if ( content.trim_end_seconds != null ) { + finalizeBody.trim_end_seconds = content.trim_end_seconds; + } + var preserveCheckbox = el( 'rd-preserve-original' ); + if ( preserveCheckbox && preserveCheckbox.checked ) { + finalizeBody.preserve_original = true; + } + body = JSON.stringify( finalizeBody ); } finalizeBtn.disabled = true; @@ -966,26 +991,165 @@ var token = mw.config.get( 'wgUploadToken' ); var user = mw.config.get( 'wgUploadUser' ); var timestamp = mw.config.get( 'wgUploadTimestamp' ); + var gatewayUrl = mw.config.get( 'wgIpfsGatewayUrl' ) || 'https://ipfs.delivery-kid.cryptograss.live'; var draftId = video.getAttribute( 'data-draft-id' ); var filename = video.getAttribute( 'data-filename' ); + var previewStatus = el( 'rd-preview-status' ); + var hlsInfo = el( 'rd-hls-info' ); - if ( !deliveryKidUrl || !token || !draftId || !filename ) { - // Not logged in or missing config — hide the player - var container = el( 'rd-video-preview' ); - if ( container ) { - container.style.display = 'none'; - } + if ( !deliveryKidUrl || !draftId ) { return; } - var src = deliveryKidUrl + '/staging/drafts/' + - encodeURIComponent( draftId ) + '/' + - encodeURIComponent( filename ) + - '?token=' + encodeURIComponent( token ) + - '&user=' + encodeURIComponent( user ) + - '×tamp=' + encodeURIComponent( timestamp ); + function setVideoSrc( src ) { + video.src = src; + video.style.display = ''; + if ( previewStatus ) { + previewStatus.style.display = 'none'; + } + } + + function showPreviewStatus( msg ) { + if ( previewStatus ) { + previewStatus.textContent = msg; + previewStatus.style.display = ''; + } + video.style.display = 'none'; + } + + function loadFromStaging() { + if ( !token || !filename ) { + return false; + } + var src = deliveryKidUrl + '/staging/drafts/' + + encodeURIComponent( draftId ) + '/' + + encodeURIComponent( filename ) + + '?token=' + encodeURIComponent( token ) + + '&user=' + encodeURIComponent( user ) + + '×tamp=' + encodeURIComponent( timestamp ); + setVideoSrc( src ); + return true; + } + + function pollForPreview() { + showPreviewStatus( 'Preview is being transcoded...' ); + if ( hlsInfo ) { + hlsInfo.textContent = 'AV1 HLS transcoding in progress. Preview will appear when ready.'; + } + + var headers = {}; + if ( token ) { + headers[ 'X-Upload-Token' ] = token; + headers[ 'X-Upload-User' ] = user; + headers[ 'X-Upload-Timestamp' ] = String( timestamp ); + } + + var pollInterval = setInterval( function () { + fetch( deliveryKidUrl + '/draft-content/' + draftId, { + headers: headers + } ).then( function ( resp ) { + if ( !resp.ok ) { + return null; + } + return resp.json(); + } ).then( function ( data ) { + if ( !data ) { + return; + } + if ( data.preview_status === 'ready' && data.preview_mp4_cid ) { + clearInterval( pollInterval ); + setVideoSrc( gatewayUrl + '/ipfs/' + data.preview_mp4_cid ); + if ( hlsInfo ) { + hlsInfo.textContent = 'AV1 HLS transcode complete. Ready to finalize.'; + } + } else if ( data.preview_status === 'failed' ) { + clearInterval( pollInterval ); + if ( !loadFromStaging() ) { + showPreviewStatus( 'Preview transcoding failed.' ); + } + if ( hlsInfo ) { + hlsInfo.textContent = 'Preview transcoding failed. Video will be transcoded on finalization.'; + } + } + } ).catch( function () { + // Silently retry on network errors + } ); + }, 10000 ); + } + + // Check delivery-kid for preview status + if ( token ) { + showPreviewStatus( 'Checking preview...' ); + var headers = {}; + headers[ 'X-Upload-Token' ] = token; + headers[ 'X-Upload-User' ] = user; + headers[ 'X-Upload-Timestamp' ] = String( timestamp ); + + fetch( deliveryKidUrl + '/draft-content/' + draftId, { + headers: headers + } ).then( function ( resp ) { + return resp.ok ? resp.json() : null; + } ).then( function ( data ) { + if ( data && data.preview_status === 'ready' && data.preview_mp4_cid ) { + setVideoSrc( gatewayUrl + '/ipfs/' + data.preview_mp4_cid ); + if ( hlsInfo ) { + hlsInfo.textContent = 'AV1 HLS transcode complete. Ready to finalize.'; + } + } else if ( data && ( data.preview_status === 'pending' || data.preview_status === 'processing' ) ) { + pollForPreview(); + } else { + loadFromStaging(); + } + } ).catch( function () { + loadFromStaging(); + } ); + } else { + showPreviewStatus( 'Log in to preview video.' ); + } + + // "Set start" / "Set end" buttons grab current playback position + var setStartBtn = el( 'rd-trim-set-start' ); + var setEndBtn = el( 'rd-trim-set-end' ); + var trimStartInput = el( 'rd-trim-start' ); + var trimEndInput = el( 'rd-trim-end' ); + var trimPreview = el( 'rd-trim-preview' ); + + function updateTrimPreview() { + if ( !trimPreview ) { + return; + } + var s = parseFloat( trimStartInput ? trimStartInput.value : '' ); + var e = parseFloat( trimEndInput ? trimEndInput.value : '' ); + if ( !isNaN( s ) && !isNaN( e ) && e > s ) { + trimPreview.textContent = 'Trimmed duration: ' + ( e - s ).toFixed( 1 ) + 's'; + } else if ( !isNaN( s ) && s > 0 ) { + trimPreview.textContent = 'Starting from ' + s.toFixed( 1 ) + 's'; + } else { + trimPreview.textContent = ''; + } + } + + if ( setStartBtn && trimStartInput ) { + setStartBtn.addEventListener( 'click', function () { + trimStartInput.value = video.currentTime.toFixed( 1 ); + updateTrimPreview(); + } ); + } + if ( setEndBtn && trimEndInput ) { + setEndBtn.addEventListener( 'click', function () { + trimEndInput.value = video.currentTime.toFixed( 1 ); + updateTrimPreview(); + } ); + } + if ( trimStartInput ) { + trimStartInput.addEventListener( 'input', updateTrimPreview ); + } + if ( trimEndInput ) { + trimEndInput.addEventListener( 'input', updateTrimPreview ); + } - video.src = src; + // Show initial trim preview if values are pre-filled + updateTrimPreview(); } // -- Init -- diff --git a/extensions/PickiPediaReleases/src/DeliveryKidUploadInput.php b/extensions/PickiPediaReleases/src/DeliveryKidUploadInput.php index 5b5280b..3780122 100644 --- a/extensions/PickiPediaReleases/src/DeliveryKidUploadInput.php +++ b/extensions/PickiPediaReleases/src/DeliveryKidUploadInput.php @@ -32,7 +32,7 @@ public static function getName(): string { /** * @return string[] */ - public static function getResourceModuleNames(): array { + public function getResourceModuleNames(): array { return [ 'ext.pickipediaReleases.deliveryKidInput' ]; } diff --git a/extensions/PickiPediaReleases/src/Hooks.php b/extensions/PickiPediaReleases/src/Hooks.php index e67c61a..5233312 100644 --- a/extensions/PickiPediaReleases/src/Hooks.php +++ b/extensions/PickiPediaReleases/src/Hooks.php @@ -90,7 +90,7 @@ public function onBeforePageDisplay( $out, $skin ): void { * * @param \PFFormPrinter &$formPrinter */ - public function onPageFormsFormPrinterSetup( &$formPrinter ): void { + public function onPageForms__FormPrinterSetup( &$formPrinter ): void { $formPrinter->registerInputType( 'MediaWiki\\Extension\\PickiPediaReleases\\DeliveryKidUploadInput' ); diff --git a/extensions/PickiPediaReleases/src/ReleaseDraftContentHandler.php b/extensions/PickiPediaReleases/src/ReleaseDraftContentHandler.php index 0949d53..9259c45 100644 --- a/extensions/PickiPediaReleases/src/ReleaseDraftContentHandler.php +++ b/extensions/PickiPediaReleases/src/ReleaseDraftContentHandler.php @@ -389,18 +389,17 @@ private function renderGenericForm( array $data, string $status ): string { $html .= Html::closeElement( 'table' ); - // Video transcoding info - $hasVideo = false; + // Video preview, trim controls + $draftId = $data['draft_id'] ?? ''; foreach ( $files as $f ) { - if ( ( $f['media_type'] ?? '' ) === 'video' ) { - $hasVideo = true; + if ( ( $f['media_type'] ?? '' ) === 'video' && $draftId ) { + $html .= $this->renderVideoPreviewAndTrim( $f, $draftId, $data, $status ); break; } } - if ( $hasVideo ) { - $html .= Html::rawElement( 'p', [ 'class' => 'uc-hls-info' ], - 'Video will be transcoded to AV1 HLS (royalty-free) automatically on finalization.' ); - } + + $html .= Html::rawElement( 'p', [ 'class' => 'uc-hls-info', 'id' => 'rd-hls-info' ], + 'Video will be transcoded to AV1 HLS (royalty-free).' ); } $html .= Html::closeElement( 'div' ); @@ -489,35 +488,113 @@ private function renderVideoForm( array $data, string $status ): string { $html .= Html::closeElement( 'table' ); - // Embed video player for preview (JS sets src with auth query params) + // Video preview, trim controls $draftId = $data['draft_id'] ?? ''; foreach ( $files as $f ) { if ( ( $f['media_type'] ?? '' ) === 'video' && $draftId ) { - $html .= Html::rawElement( 'div', [ - 'class' => 'rd-video-preview', - 'id' => 'rd-video-preview', - ], - Html::element( 'video', [ - 'id' => 'rd-video-player', - 'class' => 'rd-video-player', - 'controls' => true, - 'preload' => 'metadata', - 'data-draft-id' => $draftId, - 'data-filename' => $f['original_filename'] ?? '', - ] ) - ); - break; // Only embed first video + $html .= $this->renderVideoPreviewAndTrim( $f, $draftId, $data, $status ); + break; } } - $html .= Html::rawElement( 'p', [ 'class' => 'uc-hls-info' ], - 'Video will be transcoded to AV1 HLS (royalty-free) automatically on finalization.' ); + $html .= Html::rawElement( 'p', [ 'class' => 'uc-hls-info', 'id' => 'rd-hls-info' ], + 'Video will be transcoded to AV1 HLS (royalty-free).' ); } $html .= Html::closeElement( 'div' ); return $html; } + /** + * Render video preview player, preview status, and trim controls. + * Shared by renderGenericForm and renderVideoForm. + */ + private function renderVideoPreviewAndTrim( + array $file, string $draftId, array $data, string $status + ): string { + $html = Html::openElement( 'div', [ + 'class' => 'rd-video-preview', + 'id' => 'rd-video-preview', + ] ); + + // Preview status message (shown/hidden by JS) + $html .= Html::element( 'div', [ + 'id' => 'rd-preview-status', + 'class' => 'rd-preview-status', + ], '' ); + + $html .= Html::element( 'video', [ + 'id' => 'rd-video-player', + 'class' => 'rd-video-player', + 'controls' => true, + 'preload' => 'metadata', + 'data-draft-id' => $draftId, + 'data-filename' => $file['original_filename'] ?? '', + ] ); + + $html .= Html::closeElement( 'div' ); + + // Trim controls + $trimStart = $data['content']['trim_start_seconds'] ?? ''; + $trimEnd = $data['content']['trim_end_seconds'] ?? ''; + $trimDisabled = ( $status !== 'draft' ) ? [ 'disabled' => true ] : []; + + $html .= Html::openElement( 'div', [ + 'class' => 'rd-trim-controls', + 'id' => 'rd-trim-controls', + ] ); + $html .= Html::element( 'h4', [], 'Trim' ); + + $html .= Html::openElement( 'div', [ 'class' => 'rd-trim-row' ] ); + + $html .= Html::openElement( 'div', [ 'class' => 'rd-trim-field' ] ); + $html .= Html::element( 'label', [ 'for' => 'rd-trim-start' ], 'Start' ); + $html .= Html::element( 'input', array_merge( [ + 'type' => 'number', + 'id' => 'rd-trim-start', + 'class' => 'cdx-text-input__input rd-trim-input', + 'step' => '0.1', + 'min' => '0', + 'value' => $trimStart, + 'placeholder' => '0.0', + ], $trimDisabled ) ); + $html .= Html::element( 'button', array_merge( [ + 'type' => 'button', + 'id' => 'rd-trim-set-start', + 'class' => 'cdx-button rd-trim-set-btn', + ], $trimDisabled ), 'Set to current' ); + $html .= Html::closeElement( 'div' ); + + $html .= Html::openElement( 'div', [ 'class' => 'rd-trim-field' ] ); + $html .= Html::element( 'label', [ 'for' => 'rd-trim-end' ], 'End' ); + $html .= Html::element( 'input', array_merge( [ + 'type' => 'number', + 'id' => 'rd-trim-end', + 'class' => 'cdx-text-input__input rd-trim-input', + 'step' => '0.1', + 'min' => '0', + 'value' => $trimEnd, + 'placeholder' => '0.0', + ], $trimDisabled ) ); + $html .= Html::element( 'button', array_merge( [ + 'type' => 'button', + 'id' => 'rd-trim-set-end', + 'class' => 'cdx-button rd-trim-set-btn', + ], $trimDisabled ), 'Set to current' ); + $html .= Html::closeElement( 'div' ); + + $html .= Html::closeElement( 'div' ); // rd-trim-row + + $html .= Html::element( 'div', [ + 'id' => 'rd-trim-preview', + 'class' => 'rd-trim-preview', + ], '' ); + + $html .= Html::closeElement( 'div' ); // rd-trim-controls + + return $html; + } + private function renderBlockheightField( array $data ): string { $blockheight = $data['blockheight'] ?? ''; @@ -584,6 +661,18 @@ private function renderActions( array $data ): string { 'class' => 'cdx-button cdx-button--action-progressive', ], 'Save Draft' ); + // Preserve original checkbox + $html .= Html::openElement( 'label', [ + 'class' => 'rd-preserve-label', + 'for' => 'rd-preserve-original', + ] ); + $html .= Html::element( 'input', [ + 'type' => 'checkbox', + 'id' => 'rd-preserve-original', + ] ); + $html .= ' Keep original file'; + $html .= Html::closeElement( 'label' ); + $html .= Html::element( 'button', [ 'type' => 'button', 'id' => 'rd-finalize-btn',