testimonials 0.4.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de1f909878b18970be1bef107ddcc34e863b1e4118ba404b80bb894a36c39eb2
4
- data.tar.gz: ea00c89fee76e431d36cd7034ca19987851aee12a9c326e50cdd851fd8c60861
3
+ metadata.gz: eec2476402ec7e3a4d2c414cad340aae35a0456db87d65c950d527e983ab7053
4
+ data.tar.gz: a910019755bf56f04cdfc180c6706be9a1b6fe2246c481822a647610a63a53f5
5
5
  SHA512:
6
- metadata.gz: 322db6f66cc308f1aa827fa2d56fa171fd8bd14471208f20d2a4a8f6c548ff66cc3d59df5bb3e1b2fa167229bd23dec608deb21b2040ebf6daea984c8d8b45c5
7
- data.tar.gz: 1ad3ecfa42433b655b074c99b1fe3d65b7e7047f1dab4e023eb6439d685e82b0dceef875919e6a5857c8e0b3f0839623701bf4ba11f4c810952108843656a1fe
6
+ metadata.gz: 84e12d4988eddabcba1ba473b7ddb92158674f34051fdf7005aae11171962b686621c81aade5121c33a700363422f1843183d6bb4d81c32123e2a84c757c51ee
7
+ data.tar.gz: 97731aaeffebf8bad9e02e44005a69d1507c99b46badda66735d3c4d5b756dbcb06eda88e10a3716d36ef13a82d9c3f962ac9993f0c2968e8d2bd39f8443b576
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1
4
+
5
+ - Video poster frames: a still is captured from the camera at record time and
6
+ shown via `<video poster>`, so recorded videos display a real thumbnail
7
+ instead of a black box in every browser (Safari won't paint a
8
+ fragmented-MP4 frame from the file itself). Served at `/:id/poster`,
9
+ exposed as `poster_url` in the API.
10
+
3
11
  ## 0.4.0
4
12
 
5
13
  - Consent is now an explicit public/private choice instead of a single
@@ -33,7 +33,8 @@ module Testimonials
33
33
  featured: testimonial.featured?,
34
34
  created_at: testimonial.created_at.iso8601,
35
35
  avatar_url: (testimonial_avatar_url(testimonial) if testimonial.avatar_attached?),
36
- video_url: (testimonial_video_url(testimonial) if testimonial.video_attached?)
36
+ video_url: (testimonial_video_url(testimonial) if testimonial.video_attached?),
37
+ poster_url: (testimonial_poster_url(testimonial) if testimonial.poster_attached?)
37
38
  }
38
39
  end
39
40
  end
@@ -17,6 +17,12 @@ module Testimonials
17
17
  redirect_to main_app.rails_blob_path(@testimonial.video_file, disposition: disposition)
18
18
  end
19
19
 
20
+ def poster
21
+ head :not_found and return unless @testimonial.poster_attached?
22
+
23
+ redirect_to main_app.rails_blob_path(@testimonial.poster, disposition: disposition)
24
+ end
25
+
20
26
  def avatar
21
27
  head :not_found and return unless @testimonial.avatar_attached?
22
28
 
@@ -55,7 +55,10 @@ module Testimonials
55
55
  if testimonial.save
56
56
  # Purge only after a valid save, so a failed update can't strand a
57
57
  # review with its video already gone.
58
- testimonial.video_file.purge if remove_video? && testimonial.video_attached?
58
+ if remove_video? && testimonial.video_attached?
59
+ testimonial.video_file.purge
60
+ testimonial.poster.purge if testimonial.poster_attached?
61
+ end
59
62
  record_submission
60
63
  notify_host(testimonial)
61
64
  head :created
@@ -154,9 +157,27 @@ module Testimonials
154
157
  return error_label(:error_save) unless file.content_type.to_s.start_with?('video/', 'audio/')
155
158
 
156
159
  testimonial.video_file.attach(file)
160
+ attach_poster(testimonial)
157
161
  nil
158
162
  end
159
163
 
164
+ # The poster rides with a new video upload. It's best-effort — a missing
165
+ # or malformed poster never blocks the testimonial. Attaching replaces any
166
+ # previous poster; a new video with no usable poster (e.g. an uploaded
167
+ # file, which we can't frame-grab) drops the stale one so it can't show a
168
+ # thumbnail from the wrong video.
169
+ def attach_poster(testimonial)
170
+ file = params.dig(:testimonial, :poster)
171
+ usable = file.present? && file.content_type.to_s.start_with?('image/') &&
172
+ file.size <= Testimonials.config.max_avatar_size
173
+
174
+ if usable
175
+ testimonial.poster.attach(file)
176
+ elsif testimonial.poster_attached?
177
+ testimonial.poster.purge
178
+ end
179
+ end
180
+
160
181
  def attach_avatar(testimonial)
161
182
  file = params.dig(:testimonial, :avatar)
162
183
  return nil if file.blank?
@@ -11,6 +11,7 @@ module Testimonials
11
11
 
12
12
  if defined?(::ActiveStorage)
13
13
  has_one_attached :video_file
14
+ has_one_attached :poster # a still frame for the video, captured at record time
14
15
  has_one_attached :avatar
15
16
  end
16
17
 
@@ -37,6 +38,10 @@ module Testimonials
37
38
  respond_to?(:video_file) && video_file.attached?
38
39
  end
39
40
 
41
+ def poster_attached?
42
+ respond_to?(:poster) && poster.attached?
43
+ end
44
+
40
45
  def avatar_attached?
41
46
  respond_to?(:avatar) && avatar.attached?
42
47
  end
@@ -17,10 +17,11 @@
17
17
 
18
18
  <div class="card pad" style="margin-bottom: 16px;">
19
19
  <% if @testimonial.video_attached? %>
20
- <%# No #t=0.1 fragment: MediaRecorder webm has no seek index, and
21
- Firefox honors media fragments literally the pending seek stalls
22
- playback right after play. preload="metadata" paints the frame. %>
20
+ <%# The stored poster shows a real frame with no decoding Safari won't
21
+ paint a fragmented-MP4 frame on its own, so preload/fragment tricks
22
+ leave it black. %>
23
23
  <video class="playback" controls preload="metadata"
24
+ poster="<%= testimonial_poster_path(@testimonial) if @testimonial.poster_attached? %>"
24
25
  src="<%= testimonial_video_path(@testimonial) %>"></video>
25
26
  <p class="muted">
26
27
  <%= link_to "⬇ #{t('testimonials.dashboard.download_video', default: 'Download video')}",
data/config/routes.rb CHANGED
@@ -21,6 +21,7 @@ Testimonials::Engine.routes.draw do
21
21
 
22
22
  # Media by testimonial id, gated (admin, author, or public_api + publishable).
23
23
  get ':id/video', to: 'media#video', as: :testimonial_video, constraints: { id: /\d+/ }
24
+ get ':id/poster', to: 'media#poster', as: :testimonial_poster, constraints: { id: /\d+/ }
24
25
  get ':id/avatar', to: 'media#avatar', as: :testimonial_avatar, constraints: { id: /\d+/ }
25
26
 
26
27
  # Flat, human URLs: the mount path IS the resource. /testimonials is the
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Testimonials
4
- VERSION = '0.4.0'
4
+ VERSION = '0.4.1'
5
5
  end
@@ -163,6 +163,7 @@
163
163
  if (!overlay) return;
164
164
  stopMedia();
165
165
  if (state && state.playbackUrl) URL.revokeObjectURL(state.playbackUrl);
166
+ if (state && state.posterUrl) URL.revokeObjectURL(state.posterUrl);
166
167
  if (state && state.auto && !state.submitted) postEvent(state.kind, "dismissed");
167
168
  overlay.remove();
168
169
  overlay = null;
@@ -276,6 +277,7 @@
276
277
  state.body = config.existing.body || "";
277
278
  state.consent = !!config.existing.consent;
278
279
  state.existingVideoUrl = config.existing.videoUrl || null;
280
+ state.existingPosterUrl = config.existing.posterUrl || null;
279
281
  }
280
282
 
281
283
  // --- stage 2: the testimonial form ------------------------------------------
@@ -423,12 +425,9 @@
423
425
  playback.controls = true;
424
426
  playback.playsInline = true;
425
427
  playback.preload = "metadata";
426
- // No #t=0.1 fragment here either (see the review-stage comment):
427
- // MediaRecorder webm has no seek index, and Firefox honors the
428
- // fragment literally — the pending seek stalls playback a moment
429
- // after play. preload="metadata" already paints the first frame in
430
- // Chrome and Firefox; iOS shows the controls on a blank frame, which
431
- // beats a player that pauses itself.
428
+ // The stored poster shows a real frame with no decoding — the reliable
429
+ // fix for Safari, which won't paint a fragmented-MP4 frame on its own.
430
+ if (state.existingPosterUrl) playback.poster = state.existingPosterUrl;
432
431
  playback.src = state.existingVideoUrl;
433
432
  wrap.appendChild(playback);
434
433
  wrap.appendChild(attachedVideoChip(form, wrap, playback, function () {
@@ -551,7 +550,7 @@
551
550
  runCountdown(countdown, 3, function () {
552
551
  start.hidden = true;
553
552
  stop.hidden = false;
554
- beginRecording(timer, stop);
553
+ beginRecording(preview, timer, stop);
555
554
  });
556
555
  });
557
556
 
@@ -582,7 +581,7 @@
582
581
  }, 800);
583
582
  }
584
583
 
585
- function beginRecording(timer, stop) {
584
+ function beginRecording(preview, timer, stop) {
586
585
  media.chunks = [];
587
586
  var recorder;
588
587
  try {
@@ -598,6 +597,12 @@
598
597
  // A teardown (Cancel, close) also stops the recorder — only a real
599
598
  // Finish, where this recorder is still the active one, goes to review.
600
599
  if (media.recorder !== recorder) return;
600
+ // Grab a poster frame from the still-live preview stream *before*
601
+ // showStage tears the camera down. Captured from the camera, never
602
+ // from the recorded blob — seeking Safari's own fragmented MP4 to
603
+ // paint a frame is exactly what fails (black or stall), so we store a
604
+ // real image and hand it to <video poster>, which needs no decoding.
605
+ capturePoster(preview);
601
606
  var type = recorder.mimeType || "video/webm";
602
607
  var blob = new Blob(media.chunks, { type: type.split(";")[0] });
603
608
  showStage(reviewStage(blob));
@@ -668,9 +673,13 @@
668
673
  state.playbackUrl = URL.createObjectURL(blob);
669
674
  // No #t=0.1 here, on purpose: seeking a blob of Safari's own fragmented
670
675
  // MP4 recording can stall the video track while audio keeps playing.
671
- // The fragment-thumbnail trick is only for server-served files; a
672
- // just-recorded clip gets played immediately anyway.
676
+ // The captured poster (below) shows a frame without any decoding.
673
677
  playback.src = state.playbackUrl;
678
+ if (state.posterBlob) {
679
+ if (state.posterUrl) URL.revokeObjectURL(state.posterUrl);
680
+ state.posterUrl = URL.createObjectURL(state.posterBlob);
681
+ playback.poster = state.posterUrl;
682
+ }
674
683
  stage.appendChild(playback);
675
684
 
676
685
  var error = el("p", "tml-error");
@@ -722,6 +731,24 @@
722
731
  media = { stream: null, recorder: null, chunks: [], timer: null };
723
732
  }
724
733
 
734
+ // Draw the current preview frame to a canvas *now* (synchronous, while the
735
+ // stream is live) and encode it as a JPEG poster. The canvas keeps the
736
+ // pixels, so the async toBlob is safe even after the camera stops.
737
+ function capturePoster(preview) {
738
+ try {
739
+ var w = preview.videoWidth;
740
+ var h = preview.videoHeight;
741
+ if (!w || !h) return;
742
+ var canvas = document.createElement("canvas");
743
+ canvas.width = w;
744
+ canvas.height = h;
745
+ canvas.getContext("2d").drawImage(preview, 0, 0, w, h);
746
+ canvas.toBlob(function (blob) {
747
+ if (state && blob) state.posterBlob = blob;
748
+ }, "image/jpeg", 0.8);
749
+ } catch (e) { /* poster is best-effort; a black frame is not fatal */ }
750
+ }
751
+
725
752
  // --- NPS ----------------------------------------------------------------------
726
753
 
727
754
  function npsStage() {
@@ -858,6 +885,9 @@
858
885
  if (state.videoBlob) {
859
886
  var extension = (state.videoBlob.type || "video/webm").indexOf("mp4") > -1 ? "mp4" : "webm";
860
887
  data.append("testimonial[video_file]", state.videoBlob, "testimonial." + extension);
888
+ // The poster frame captured at record time, so the stored video shows
889
+ // a real thumbnail instead of a black box in every browser.
890
+ if (state.posterBlob) data.append("testimonial[poster]", state.posterBlob, "poster.jpg");
861
891
  } else if (state.removeExistingVideo) {
862
892
  data.append("testimonial[remove_video]", "1");
863
893
  }
@@ -86,7 +86,8 @@ module Testimonials
86
86
  rating: existing.rating,
87
87
  body: existing.body,
88
88
  consent: existing.consent_given ? true : false,
89
- videoUrl: existing_video_url(existing)
89
+ videoUrl: existing_video_url(existing),
90
+ posterUrl: existing_poster_url(existing)
90
91
  },
91
92
  video: {
92
93
  enabled: config.video_enabled?,
@@ -112,6 +113,12 @@ module Testimonials
112
113
  "#{Testimonials.config.mount_path.chomp('/')}/#{existing.id}/video"
113
114
  end
114
115
 
116
+ def existing_poster_url(existing)
117
+ return unless existing.poster_attached?
118
+
119
+ "#{Testimonials.config.mount_path.chomp('/')}/#{existing.id}/poster"
120
+ end
121
+
115
122
  # Every user-facing string in the widget, resolved through Rails I18n so
116
123
  # the form follows the app's current locale. Each lookup carries an
117
124
  # English default, so the widget stays fully worded even when a key is
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: testimonials
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov