From 97428b5a782a0c5116374789022a399fb8a2de2d Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 3 Sep 2026 10:08:20 -0400 Subject: [PATCH 1/3] feat(webv2): present reference trims as a sliding sample window; add steppers Reference frames cost denoise VRAM on every step, so the useful sample of a reference video is a short window (a few seconds), not the whole clip - and picking a small interval with independent start/end sliders meant fighting both ends. - The video reference card's second row is now the SAMPLE LENGTH in frames (min 1, max = frames remaining after start) instead of the end frame. Moving the start slides the window at constant length until it hits the clip's end. The row's thumbnail still shows the resulting end frame, badged with that frame number since the number field beside it now shows the length. - Newly added video references default to a 200-frame window (~8s at 24 fps; DEFAULT_REFERENCE_SAMPLE_FRAMES in core/settings) instead of the whole clip. - All trim sliders - both reference rows and the Initial Video's start/end - gain increment/decrement steppers (SliderNumberField's existing showStepper) for single-frame nudging. Storage is unchanged: clips still persist startFrame/endFrame, so the request contract, normalization and recall are untouched; length is derived as end - start + 1. Co-Authored-By: Claude Fable 5 --- .../frontend/webv2/public/locales/en.json | 1 + .../webv2/src/features/video/core/settings.ts | 8 +++ .../video/ui/VideoReferenceListField.tsx | 62 ++++++++++++------- .../video/ui/VideoSourceClipField.tsx | 2 + 4 files changed, 52 insertions(+), 21 deletions(-) diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 17154e7b0b5..d802b2f99e6 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -3405,6 +3405,7 @@ "removeFrame": "Remove", "replaceClip": "Replace the initial video", "replaceFrame": "Replace the frame image", + "sampleLength": "Sample Length (Frames)", "trim": "Trim", "trimEnd": "End Frame", "trimEndShort": "End", diff --git a/invokeai/frontend/webv2/src/features/video/core/settings.ts b/invokeai/frontend/webv2/src/features/video/core/settings.ts index 4cbc9cd8558..e3b0ddc9fc7 100644 --- a/invokeai/frontend/webv2/src/features/video/core/settings.ts +++ b/invokeai/frontend/webv2/src/features/video/core/settings.ts @@ -79,6 +79,14 @@ export const isVideoSourceClip = (value: unknown): value is VideoSourceClip => export const VIDEO_REFERENCE_MAX_VIDEOS = 3; export const VIDEO_REFERENCE_MAX_IMAGES = 9; +/** + * Default sample length (in frames) for a newly added video reference. Reference rows cost + * VRAM in every denoise step — the packed sequence grows with reference length — so the + * useful sample is a short window that captures the wanted visual/audio features, not the + * whole clip. 200 frames ≈ 8s at the models' native 24 fps. + */ +export const DEFAULT_REFERENCE_SAMPLE_FRAMES = 200; + const VIDEO_REFERENCE_CONDITIONINGS = ['video_audio', 'video', 'audio'] as const; const VIDEO_REFERENCE_IMAGE_DETAILS = ['max', 'match'] as const; diff --git a/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx b/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx index c990a04aa1b..b20dc47efc6 100644 --- a/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx +++ b/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx @@ -10,7 +10,7 @@ import { Badge, Box, createListCollection, HStack, Image, Input, Spinner, Stack, import { useDndContext, useDndMonitor, useDroppable } from '@dnd-kit/core'; import { galleryImages, galleryItems, galleryTransfers } from '@features/gallery'; import { galleryImageUrls, galleryVideoUrls, isGalleryItemDragData } from '@features/gallery/utility'; -import { createVideoSourceClip } from '@features/video/core/settings'; +import { createVideoSourceClip, DEFAULT_REFERENCE_SAMPLE_FRAMES } from '@features/video/core/settings'; import { assertAccountScopeCurrent, captureAccountScope, @@ -106,24 +106,33 @@ const ReferenceCard = memo(function ReferenceCard({ }, [index, onUpdate, reference] ); + // The trim is presented as a sliding sample window — start frame plus length — because + // what the user is choosing is "how much" (every reference frame costs denoise VRAM) and + // "from where". Storage stays startFrame/endFrame (the request contract): length is + // derived as end - start + 1, and moving the start slides the window at constant length + // until it hits the clip's end, where it shrinks. const handleStartFrame = useCallback( - (startFrame: number) => { + (rawStart: number) => { if (reference.kind === 'video') { + const maxFrame = Math.max(0, reference.clip.numFrames - 1); + const sampleFrames = reference.clip.endFrame - reference.clip.startFrame + 1; + const startFrame = Math.min(Math.max(0, rawStart), maxFrame); + onUpdate(index, { ...reference, - clip: { ...reference.clip, endFrame: Math.max(startFrame, reference.clip.endFrame), startFrame }, + clip: { ...reference.clip, endFrame: Math.min(startFrame + sampleFrames - 1, maxFrame), startFrame }, }); } }, [index, onUpdate, reference] ); - const handleEndFrame = useCallback( - (endFrame: number) => { + const handleSampleFrames = useCallback( + (rawSampleFrames: number) => { if (reference.kind === 'video') { - onUpdate(index, { - ...reference, - clip: { ...reference.clip, endFrame, startFrame: Math.min(reference.clip.startFrame, endFrame) }, - }); + const maxFrame = Math.max(0, reference.clip.numFrames - 1); + const endFrame = Math.min(reference.clip.startFrame + Math.max(1, rawSampleFrames) - 1, maxFrame); + + onUpdate(index, { ...reference, clip: { ...reference.clip, endFrame } }); } }, [index, onUpdate, reference] @@ -160,9 +169,12 @@ const ReferenceCard = memo(function ReferenceCard({ value={selectValue} onValueChange={handleSelect} /> - {/* One row per trim bound: the bound's live frame at left, its slider at - right. The seeking thumbs replace the static gallery poster for video - references — the start-frame thumb is the card's visual identity. */} + {/* One row per window edge: the live frame at left, its control at right. The + seeking thumbs replace the static gallery poster for video references — the + start-frame thumb is the card's visual identity. The second row's SLIDER is + the sample length (the quantity that costs VRAM); its THUMB still shows the + resulting end frame, badged with that frame number since the number field + beside it shows the length, not the frame. */} {reference.kind === 'video' ? ( @@ -178,6 +190,7 @@ const ReferenceCard = memo(function ReferenceCard({ disabled={disabled} max={Math.max(0, reference.clip.numFrames - 1)} min={0} + showStepper step={1} value={reference.clip.startFrame} onChange={handleStartFrame} @@ -188,18 +201,19 @@ const ReferenceCard = memo(function ReferenceCard({ @@ -390,9 +404,15 @@ export const VideoReferenceListField = memo(function VideoReferenceListField({ return [ ...current, { - // References are truncated to the generated duration, not joined: default to - // the whole clip rather than the extend-mode 2-frame-tail trim. - clip: { ...clip, endFrame: Math.max(0, clip.numFrames - 1), startFrame: 0 }, + // Default to a short sample window from the clip's start, not the whole + // clip: reference frames cost denoise VRAM every step, and a few seconds + // captures the wanted features. (Not the extend-mode 2-frame-tail trim + // either — references are truncated to the generated duration, not joined.) + clip: { + ...clip, + endFrame: Math.max(0, Math.min(DEFAULT_REFERENCE_SAMPLE_FRAMES, clip.numFrames) - 1), + startFrame: 0, + }, conditioning: 'video_audio', kind: 'video', }, diff --git a/invokeai/frontend/webv2/src/features/video/ui/VideoSourceClipField.tsx b/invokeai/frontend/webv2/src/features/video/ui/VideoSourceClipField.tsx index 831e61b5423..5e117198e9e 100644 --- a/invokeai/frontend/webv2/src/features/video/ui/VideoSourceClipField.tsx +++ b/invokeai/frontend/webv2/src/features/video/ui/VideoSourceClipField.tsx @@ -286,6 +286,7 @@ export const VideoSourceClipField = memo( disabled={disabled} max={maxFrameIndex} min={0} + showStepper step={1} value={sourceVideo.startFrame} onChange={setStartFrame} @@ -305,6 +306,7 @@ export const VideoSourceClipField = memo( disabled={disabled} max={maxFrameIndex} min={0} + showStepper step={1} value={sourceVideo.endFrame} onChange={setEndFrame} From 0560663c4783d9b6a20c996d2f7a4b2b86cdc2b6 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 3 Sep 2026 10:23:51 -0400 Subject: [PATCH 2/3] fix reference sample window per adversarial review; extract and test the math - The reference-extend anchor's end frame stays PINNED to the Initial Video cutpoint: its start slider adjusts only the lead-in, and its length control grows the window backward from the pinned end (ceiling: the available lead-in). The first cut let a start-slider round trip silently detach the anchor's window from the seam. - An ordinary window's slide now stops at the clip's end instead of shrinking, so a transient drag overshoot can no longer ratchet a 200-frame sample down to 1 frame. - The window math moved to core/settings (slideReferenceSampleWindow / resizeReferenceSampleWindow), is self-healing by construction (0 <= start <= end <= numFrames-1 even from corrupt persisted trims, which the first cut could make worse), and has 11 unit tests covering the wall stop, anchor pinning, corrupt-trim healing and single-frame clips. Co-Authored-By: Claude Fable 5 --- .../src/features/video/core/settings.test.ts | 84 +++++++++++++++++++ .../webv2/src/features/video/core/settings.ts | 59 +++++++++++++ .../video/ui/VideoReferenceListField.tsx | 35 ++++---- 3 files changed, 164 insertions(+), 14 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/video/core/settings.test.ts b/invokeai/frontend/webv2/src/features/video/core/settings.test.ts index 19f0ac57711..ca57edd3943 100644 --- a/invokeai/frontend/webv2/src/features/video/core/settings.test.ts +++ b/invokeai/frontend/webv2/src/features/video/core/settings.test.ts @@ -4,6 +4,8 @@ import type { VideoReferenceItem, VideoSettings } from './types'; import { MINIMAX_H3_NUM_FRAMES_CHOICES } from './dimensions'; import { + resizeReferenceSampleWindow, + slideReferenceSampleWindow, applyReferenceExtendSourceVideo, applyReferenceExtendNumFrames, canPlaceReferenceExtendAnchor, @@ -869,3 +871,85 @@ describe('reference-extend linkage', () => { expect(applyReferenceExtendNumFrames(unlinked, 90)).toBe(unlinked); }); }); + +describe('reference sample window', () => { + const clip = (startFrame: number, endFrame: number, numFrames = 300) => ({ + endFrame, + fps: 24, + height: 480, + numFrames, + startFrame, + video_name: 'clip.mp4', + width: 640, + }); + + describe('slideReferenceSampleWindow', () => { + it('slides an ordinary window at constant length', () => { + const next = slideReferenceSampleWindow(clip(0, 199), 50, false); + expect([next.startFrame, next.endFrame]).toEqual([50, 249]); + }); + + it('stops at the clip end instead of shrinking (no overshoot ratchet)', () => { + // Drag far past the wall, then back to 0: the length must survive the round trip. + const overshot = slideReferenceSampleWindow(clip(0, 199), 299, false); + expect([overshot.startFrame, overshot.endFrame]).toEqual([100, 299]); + const back = slideReferenceSampleWindow(overshot, 0, false); + expect([back.startFrame, back.endFrame]).toEqual([0, 199]); + }); + + it('keeps the extend anchor end pinned to the cutpoint', () => { + // The anchor's seam continuity depends on frames adjacent to its end frame. + const next = slideReferenceSampleWindow(clip(180, 298), 200, true); + expect([next.startFrame, next.endFrame]).toEqual([200, 298]); + const backAndForth = slideReferenceSampleWindow(slideReferenceSampleWindow(next, 250, true), 200, true); + expect([backAndForth.startFrame, backAndForth.endFrame]).toEqual([200, 298]); + }); + + it('clamps the anchor start to its pinned end', () => { + const next = slideReferenceSampleWindow(clip(180, 298), 500, true); + expect([next.startFrame, next.endFrame]).toEqual([298, 298]); + }); + + it('self-heals a corrupt persisted trim into bounds', () => { + // end < start and end beyond the clip must both come back as a valid window. + const inverted = slideReferenceSampleWindow(clip(10, 5, 20), 0, false); + expect(inverted.startFrame).toBeGreaterThanOrEqual(0); + expect(inverted.endFrame).toBeGreaterThanOrEqual(inverted.startFrame); + expect(inverted.endFrame).toBeLessThanOrEqual(19); + const oversized = slideReferenceSampleWindow(clip(0, 999, 20), 5, false); + expect([oversized.startFrame, oversized.endFrame]).toEqual([0, 19]); + }); + + it('handles a single-frame clip', () => { + const next = slideReferenceSampleWindow(clip(0, 0, 1), 5, false); + expect([next.startFrame, next.endFrame]).toEqual([0, 0]); + }); + }); + + describe('resizeReferenceSampleWindow', () => { + it('grows an ordinary window forward from its start', () => { + const next = resizeReferenceSampleWindow(clip(50, 60), 100, false); + expect([next.startFrame, next.endFrame]).toEqual([50, 149]); + }); + + it('clamps the length to the clip end', () => { + const next = resizeReferenceSampleWindow(clip(250, 260), 100, false); + expect([next.startFrame, next.endFrame]).toEqual([250, 299]); + }); + + it('grows the extend anchor backward from its pinned end', () => { + const next = resizeReferenceSampleWindow(clip(280, 298), 100, true); + expect([next.startFrame, next.endFrame]).toEqual([199, 298]); + }); + + it('clamps the anchor lead-in at the clip start', () => { + const next = resizeReferenceSampleWindow(clip(280, 298), 1000, true); + expect([next.startFrame, next.endFrame]).toEqual([0, 298]); + }); + + it('never produces a window shorter than one frame', () => { + const next = resizeReferenceSampleWindow(clip(50, 199), -5, false); + expect([next.startFrame, next.endFrame]).toEqual([50, 50]); + }); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/video/core/settings.ts b/invokeai/frontend/webv2/src/features/video/core/settings.ts index e3b0ddc9fc7..2445a82563a 100644 --- a/invokeai/frontend/webv2/src/features/video/core/settings.ts +++ b/invokeai/frontend/webv2/src/features/video/core/settings.ts @@ -87,6 +87,65 @@ export const VIDEO_REFERENCE_MAX_IMAGES = 9; */ export const DEFAULT_REFERENCE_SAMPLE_FRAMES = 200; +/** + * Move a reference clip's sample window to a new start frame. + * + * Ordinary references slide at CONSTANT length, stopping at the clip's end rather than + * shrinking — a transient overshoot during a drag must not ratchet the sample down. The + * reference-extend anchor (`pinEnd`) instead keeps its end frame pinned to the Initial + * Video cutpoint (seam continuity depends on the frames adjacent to it; see + * deriveReferenceExtendClip), so moving its start only adjusts the lead-in length. + * + * Self-healing by construction: the returned window always satisfies + * 0 <= start <= end <= numFrames - 1, even from a corrupt persisted trim. + */ +export const slideReferenceSampleWindow = ( + clip: VideoSourceClip, + rawStart: number, + pinEnd: boolean +): VideoSourceClip => { + const maxFrame = Math.max(0, clip.numFrames - 1); + + if (pinEnd) { + const endFrame = Math.min(Math.max(0, clip.endFrame), maxFrame); + const startFrame = Math.min(Math.max(0, Math.round(rawStart)), endFrame); + + return { ...clip, endFrame, startFrame }; + } + + const sampleFrames = Math.min(Math.max(1, clip.endFrame - clip.startFrame + 1), maxFrame + 1); + const startFrame = Math.min(Math.max(0, Math.round(rawStart)), maxFrame - (sampleFrames - 1)); + + return { ...clip, endFrame: startFrame + sampleFrames - 1, startFrame }; +}; + +/** + * Resize a reference clip's sample window to a new length in frames. + * + * Ordinary references grow from the start frame (the end moves, clamped to the clip); the + * reference-extend anchor (`pinEnd`) grows backward from its pinned end (the start moves), + * since its end must stay on the Initial Video cutpoint. Same self-healing bounds as + * slideReferenceSampleWindow. + */ +export const resizeReferenceSampleWindow = ( + clip: VideoSourceClip, + rawSampleFrames: number, + pinEnd: boolean +): VideoSourceClip => { + const maxFrame = Math.max(0, clip.numFrames - 1); + const sampleFrames = Math.min(Math.max(1, Math.round(rawSampleFrames)), maxFrame + 1); + + if (pinEnd) { + const endFrame = Math.min(Math.max(0, clip.endFrame), maxFrame); + + return { ...clip, endFrame, startFrame: Math.max(0, endFrame - (sampleFrames - 1)) }; + } + + const startFrame = Math.min(Math.max(0, clip.startFrame), maxFrame); + + return { ...clip, endFrame: Math.min(startFrame + sampleFrames - 1, maxFrame), startFrame }; +}; + const VIDEO_REFERENCE_CONDITIONINGS = ['video_audio', 'video', 'audio'] as const; const VIDEO_REFERENCE_IMAGE_DETAILS = ['max', 'match'] as const; diff --git a/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx b/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx index b20dc47efc6..32bcfd9ecc6 100644 --- a/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx +++ b/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx @@ -10,7 +10,12 @@ import { Badge, Box, createListCollection, HStack, Image, Input, Spinner, Stack, import { useDndContext, useDndMonitor, useDroppable } from '@dnd-kit/core'; import { galleryImages, galleryItems, galleryTransfers } from '@features/gallery'; import { galleryImageUrls, galleryVideoUrls, isGalleryItemDragData } from '@features/gallery/utility'; -import { createVideoSourceClip, DEFAULT_REFERENCE_SAMPLE_FRAMES } from '@features/video/core/settings'; +import { + createVideoSourceClip, + DEFAULT_REFERENCE_SAMPLE_FRAMES, + resizeReferenceSampleWindow, + slideReferenceSampleWindow, +} from '@features/video/core/settings'; import { assertAccountScopeCurrent, captureAccountScope, @@ -108,19 +113,15 @@ const ReferenceCard = memo(function ReferenceCard({ ); // The trim is presented as a sliding sample window — start frame plus length — because // what the user is choosing is "how much" (every reference frame costs denoise VRAM) and - // "from where". Storage stays startFrame/endFrame (the request contract): length is - // derived as end - start + 1, and moving the start slides the window at constant length - // until it hits the clip's end, where it shrinks. + // "from where". Storage stays startFrame/endFrame (the request contract); the window + // math (constant-length slide that stops at the clip's end, and the extend anchor's + // pinned-to-the-cutpoint end) lives in core/settings. const handleStartFrame = useCallback( (rawStart: number) => { if (reference.kind === 'video') { - const maxFrame = Math.max(0, reference.clip.numFrames - 1); - const sampleFrames = reference.clip.endFrame - reference.clip.startFrame + 1; - const startFrame = Math.min(Math.max(0, rawStart), maxFrame); - onUpdate(index, { ...reference, - clip: { ...reference.clip, endFrame: Math.min(startFrame + sampleFrames - 1, maxFrame), startFrame }, + clip: slideReferenceSampleWindow(reference.clip, rawStart, reference.fromSourceVideo === true), }); } }, @@ -129,10 +130,10 @@ const ReferenceCard = memo(function ReferenceCard({ const handleSampleFrames = useCallback( (rawSampleFrames: number) => { if (reference.kind === 'video') { - const maxFrame = Math.max(0, reference.clip.numFrames - 1); - const endFrame = Math.min(reference.clip.startFrame + Math.max(1, rawSampleFrames) - 1, maxFrame); - - onUpdate(index, { ...reference, clip: { ...reference.clip, endFrame } }); + onUpdate(index, { + ...reference, + clip: resizeReferenceSampleWindow(reference.clip, rawSampleFrames, reference.fromSourceVideo === true), + }); } }, [index, onUpdate, reference] @@ -208,7 +209,13 @@ const ReferenceCard = memo(function ReferenceCard({ Date: Thu, 3 Sep 2026 13:22:57 -0400 Subject: [PATCH 3/3] feat(webv2): label the reference sample-window sliders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two controls carry different units - a frame INDEX and a frame COUNT - so the thumbnail badges alone did not say which was which. Give each row the shared uppercase FieldLabel: 'Start Frame' and 'Sample Length'. The length label also carries the window's duration in seconds ('Sample Length · 8.3s'), since hitting a target sample duration is the point of the control while its unit has to stay frames to match the trim contract. Guarded on a positive finite fps; falls back to the bare 'Sample Length (Frames)' label otherwise, which remains the aria label in both cases. Co-Authored-By: Claude Opus 5 (1M context) --- .../frontend/webv2/public/locales/en.json | 1 + .../video/ui/VideoReferenceListField.tsx | 26 ++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index d802b2f99e6..d8ef41833a2 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -3406,6 +3406,7 @@ "replaceClip": "Replace the initial video", "replaceFrame": "Replace the frame image", "sampleLength": "Sample Length (Frames)", + "sampleLengthWithSeconds": "Sample Length · {{seconds}}s", "trim": "Trim", "trimEnd": "End Frame", "trimEndShort": "End", diff --git a/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx b/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx index 32bcfd9ecc6..9e0576a337e 100644 --- a/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx +++ b/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx @@ -24,7 +24,7 @@ import { import { Button, IconButton } from '@platform/ui/Button'; import { DropTargetOverlay } from '@platform/ui/DropTargetOverlay'; import { DropZone } from '@platform/ui/DropZone'; -import { Field } from '@platform/ui/Field'; +import { Field, FieldLabel } from '@platform/ui/Field'; import { MiddleTruncate } from '@platform/ui/MiddleTruncate'; import { Select } from '@platform/ui/Select'; import { SliderNumberField } from '@platform/ui/SliderNumberField'; @@ -138,6 +138,14 @@ const ReferenceCard = memo(function ReferenceCard({ }, [index, onUpdate, reference] ); + // The window's length, and the seconds it represents — the label carries the seconds + // because the control is how a user hits a target sample duration (reference frames cost + // denoise VRAM every step), while its unit has to stay frames to match the trim contract. + const sampleFrames = reference.kind === 'video' ? reference.clip.endFrame - reference.clip.startFrame + 1 : 0; + const sampleSeconds = + reference.kind === 'video' && Number.isFinite(reference.clip.fps) && reference.clip.fps > 0 + ? (sampleFrames / reference.clip.fps).toFixed(1) + : null; const handleMoveUp = useCallback(() => onMove(index, -1), [index, onMove]); const handleMoveDown = useCallback(() => onMove(index, 1), [index, onMove]); const handleRemove = useCallback(() => onRemove(index), [index, onRemove]); @@ -185,7 +193,8 @@ const ReferenceCard = memo(function ReferenceCard({ label={t('widgets.video.trimStartShort')} src={galleryVideoUrls.full(name)} /> - + + {t('widgets.video.trimStart')} - + - + + + {sampleSeconds === null + ? t('widgets.video.sampleLength') + : t('widgets.video.sampleLengthWithSeconds', { seconds: sampleSeconds })} + - + ) : null}