From 9f183e5832f4898b4339d33c7e71b97361ce9211 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 25 Apr 2026 02:18:56 -0700 Subject: [PATCH] feat(stories): per-clip volume control on the timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each story item now carries a volume column (linear gain, default 1.0, clamped 0.0–2.0 server-side). New PUT /stories/{}/items/{}/volume route + useUpdateStoryItemVolume hook + a Volume2 icon in the clip-edit toolbar that opens a popover with a 0–200% slider. Local slider state drives the visual during a drag; the persist fires once on onValueCommit, mirroring the generation-page slider pattern. Web Audio playback inserts a per-clip GainNode between source and master so volume changes apply live without re-decoding the buffer (source -> clipGain -> masterGain -> destination). Server-side mixdown in export multiplies the trimmed clip by its volume before summing into the timeline. Split + duplicate carry the volume forward to the new clips so trimming a faded section keeps the level you set. Migration adds the volume column with default 1.0 so existing rows read as full volume. --- .../StoriesTab/StoryTrackEditor.tsx | 91 +++++++++++++++++++ app/src/lib/api/client.ts | 12 +++ app/src/lib/api/types.ts | 5 + app/src/lib/hooks/useStories.ts | 21 +++++ app/src/lib/hooks/useStoryPlayback.ts | 8 +- backend/database/migrations.py | 2 + backend/database/models.py | 1 + backend/models.py | 12 +++ backend/routes/stories.py | 14 +++ backend/services/stories.py | 40 ++++++++ 10 files changed, 205 insertions(+), 1 deletion(-) diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx index 0ef3e981..2ee2e5f6 100644 --- a/app/src/components/StoriesTab/StoryTrackEditor.tsx +++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx @@ -11,6 +11,8 @@ import { Scissors, Square, Trash2, + Volume2, + VolumeX, } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import WaveSurfer from 'wavesurfer.js'; @@ -21,6 +23,8 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Slider } from '@/components/ui/slider'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import type { StoryItemDetail } from '@/lib/api/types'; @@ -31,6 +35,7 @@ import { useSetStoryItemVersion, useSplitStoryItem, useTrimStoryItem, + useUpdateStoryItemVolume, } from '@/lib/hooks/useStories'; import { cn } from '@/lib/utils/cn'; import { useGenerationStore } from '@/stores/generationStore'; @@ -120,6 +125,66 @@ function ClipWaveform({ ); } +// Per-clip volume popover. Local state drives the slider during a drag so +// each pointer-move pixel doesn't fire a PATCH; commits on release. +function ClipVolumePopover({ + storyId, + itemId, + volume, + onChange, +}: { + storyId: string; + itemId: string; + volume: number; + onChange: (value: number) => void; +}) { + const [localVolume, setLocalVolume] = useState(volume); + // Re-sync when the selected clip changes or the persisted value updates + // out-of-band (split/duplicate carry the value forward). + useEffect(() => { + setLocalVolume(volume); + }, [volume, itemId, storyId]); + + const display = Math.round(localVolume * 100); + const Icon = localVolume === 0 ? VolumeX : Volume2; + + return ( + + + + + +
+ Volume + {display}% +
+ setLocalVolume(v / 100)} + onValueCommit={([v]) => onChange(v / 100)} + min={0} + max={200} + step={1} + aria-label="Clip volume" + /> +
+ 0% + 100% + 200% +
+
+
+ ); +} + interface StoryTrackEditorProps { storyId: string; items: StoryItemDetail[]; @@ -157,6 +222,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { const duplicateItem = useDuplicateStoryItem(); const removeItem = useRemoveStoryItem(); const setItemVersion = useSetStoryItemVersion(); + const updateVolume = useUpdateStoryItemVolume(); const { toast } = useToast(); const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration); @@ -1045,6 +1111,31 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { > + {selectedItem && ( + + updateVolume.mutate( + { + storyId, + itemId: selectedItem.id, + data: { volume: value }, + }, + { + onError: (error) => { + toast({ + title: 'Failed to update volume', + description: error instanceof Error ? error.message : String(error), + variant: 'destructive', + }); + }, + }, + ) + } + /> + )}