Commit Graph
139 Commits
Author SHA1 Message Date
yusufipek 1c24336b6a fix(billing): serialize reconciliation and record accepted cancellations 2026-09-08 16:32:11 +03:00
yusufipek 57061b5a5d fix(billing): preserve entitlements and bound cancellation cleanup
Integrate the latest cancellation-reason flow from master. Keep paid periods and independent trials intact, stop collection without erasing historical or mixed receivables, and make scheduled and partial cancellations recoverable. Add regression coverage for invoice boundaries, entitlement expiry, cancellation selection and concurrent reason writes.
2026-09-08 15:49:37 +03:00
Yusuf İpek 36b2e5c905 Merge pull request #79 from yusufipk/claude/landing-page-design-refresh-c78363
feat(landing): refresh product-focused landing page
2026-09-08 15:08:33 +03:00
yusufipek a8607e8254 feat(landing): refresh product-focused landing page 2026-09-08 15:04:25 +03:00
Yusuf İpek 7aeda83eb6 feat(billing): cancel in-app with a one-question reason
Add a "Cancel subscription" button beside "Manage Subscription" in Settings.
It opens a dialog with one optional question (five answers, no default, a
note box under the two that want detail), then schedules the Stripe
subscription to end at the close of the current period without a trip to
the portal. The answer is stored in a new subscription_cancellations table
and shown, with an all-time tally, on the admin dashboard; the category is
also mirrored onto Stripe's cancellation feedback, the free text stays local.

The cancel route claims the local cancel flag with a conditional update
before calling Stripe, so two racing requests cannot both write a reason
row, and hands the claim back when Stripe refuses. A subscription Stripe no
longer knows answers 409 with a pointer to the portal instead of a 500. The
route carries an account-keyed rate limit on top of the shared IP one.

Two fixes found on the way: the pinned Stripe API version reports
current_period_end on the subscription item rather than the subscription, so
the sync stored null for every period end; a shared helper now reads the item
first. And the RadioGroup styles targeted a data-checked attribute radix
never writes, so the checked state was invisible in the light theme.
2026-09-08 14:05:16 +03:00
Yusuf İpek d5d2f0535e Merge pull request #75 from yusufipk/claude/openframe-mouse-overlay-bug-66603b
fix(player): re-arm the cursor idle timer when playback changes
2026-09-08 13:23:44 +03:00
yusufipek 7357f24831 refactor(player): share the cursor idle hook and stop waking on element pauses
Move the cursor idle logic into useCursorIdle and use it from both the video
page and the compare page, which carried its own copy. Only pointer activity
wakes the cursor now: a pause/play pair the element emits on its own (a
rebuffer, a source switch) leaves the idle state alone instead of bringing
the chrome back for a second. The fullscreen-while-paused arming is gone,
nothing rendered it. The scrub test now leaves the player before pressing
the timeline, as the real layout forces.
2026-09-08 13:17:34 +03:00
yusufipek 142dee0c06 feat(voice-notes): convert recordings to WAV on download
MediaRecorder gives us WebM/Opus, and that is exactly what we stored and served back. Browsers and desktop players read it, but no editing suite does: DaVinci Resolve, Premiere and Final Cut all refuse the container outright, so a voice note downloaded byte-for-byte was useless to the editor it was recorded for.

The browser already decodes these formats in order to play them, so the conversion costs nothing but a RIFF header. lib/audio-to-wav.ts decodes through an OfflineAudioContext and writes interleaved 16-bit PCM. This runs at download time rather than at record time, so the stored object stays the small Opus file, uploads keep their 10MB limit, and self-hosted installs gain no server-side ffmpeg dependency.

Voice comments had no download control at all, only a play button, so reviewers were saving files straight off the audio element and getting a bare UUID. They now get a download button on both comments and replies, named after the reviewer and the frame they were talking about, gated on the same download permission as the video and asset downloads. Audio assets get a WAV / Original menu.

Files already in an editable container (wav, mp3, m4a) are handed over untouched: audio assets are not only recordings, and decoding an uploaded master back out would resample it to 48 kHz and requantise it to 16 bit for no gain. When a browser cannot decode the stored format at all, the original is saved and the user is told.
2026-09-08 13:15:28 +03:00
yusufipek b2070c1030 fix(player): re-arm the cursor idle timer when playback changes
The idle countdown that hides the cursor and the play/pause overlay was only
started from mousemove. A cursor that stayed still over the player while a
click, a key or a scrub release started playback never got a countdown, so
the overlay stayed on the video until the mouse moved again.

Arm the timer from one place and rerun it whenever playback or fullscreen
changes, keeping the cursor-over-player state in a ref so the same rule
applies from every entry point.
2026-09-08 12:55:30 +03:00
yusufipek 43cc54c0ae feat(landing): drop the toolbar overlay and gradient from the hero video 2026-09-01 15:23:17 +03:00
yusufipek 5f061d1b09 feat(landing): replace the hero image with the flow video 2026-09-01 15:12:40 +03:00
yusufipek 07a6bfbef4 feat(landing): remove the Fair Source badge from the hero 2026-08-25 07:37:30 +03:00
yusufipek cba8163286 fix(download): warn before the tab closes mid-download
Bunny and direct downloads are pulled through fetch() so we can save them
under our own filename. The browser does not treat that as a download, so
closing the tab discarded everything received so far without a word.

Register a reference counted beforeunload guard while those transfers are
in flight, and while a project manifest is being pulled file by file.
Browser owned downloads (same-origin proxy, the over-10GB fallback, asset
downloads) survive a tab close on their own and stay unguarded.
2026-08-22 12:50:45 +03:00
yusufipek d981d98cf5 feat(player): let editors upload subtitles for a version
Subtitle tracks hang off a version rather than off a video, because re-editing a cut shifts every cue. The file
always lands in our own S3-compatible storage whatever hosts the video, so a Bunny-hosted cut and an R2 one take the
same path: both already play through our own video element, so a track element is all it takes.

Uploads are normalised before they are stored. Whatever arrives, SRT or WebVTT, is parsed into cues and
re-serialised as a canonical WebVTT file, and anything we did not understand is dropped rather than passed through.
That is what makes it safe to serve a user-supplied text file from our own origin. Files saved out of Windows
editors are decoded as windows-1254 or windows-1252 when they are not valid UTF-8, rather than refused.

A YouTube version cannot carry an uploaded track, so the same CC menu drives YouTube's own captions through the
iframe module API. The embed hides YouTube's controls, so until now those captions were unreachable even when the
video had them.

Uploading and deleting take the editor permission rather than the commenter one: a subtitle is part of the
delivered cut, not a comment attachment.
2026-08-22 07:51:46 +03:00
yusufipek 0ff8b42b4a fix(bunny): read the CDN host from runtime config so Docker images can play video
NEXT_PUBLIC_BUNNY_CDN_URL is inlined into the client bundle at build time, and
the published image is built by CI without it, so the browser had no host to
build a playlist URL from no matter what the operator set in .env.docker. The
player read the empty URL as a stream that had not finished encoding and sat on
'Video Is Processing', retrying forever.

The server knows the value on every request, so the root layout now serialises
the public settings into a JSON script tag and the browser reads them from
there, falling back to the build-time variable for source builds. The
direct-download allow list came through the same broken path and moves with it.

Closes #60
2026-08-20 15:40:21 +03:00
yusufipek bcaf9e0c57 chore(comments): drop the image paste hint from the composer 2026-08-20 15:04:17 +03:00
Yusuf İpek 68d23ef1e6 Merge pull request #64 from yusufipk/fix/silent-speed-threshold
fix(player): label only 16x as silent in the speed picker
2026-08-20 11:43:53 +03:00
Yusuf İpek 14ddf19d58 Merge pull request #63 from yusufipk/feat/multi-image-comment-attachments
feat(comments): carry a batch of screenshots on one comment
2026-08-20 11:43:38 +03:00
yusufipek b040fe0dc9 fix(player): label only 16x as silent in the speed picker
The "no audio" note was attached to everything past 4x on a guess about
where the browsers stop pitch-correcting. Playing the ladder through
confirms audio survives 6x and 8x; 16x, the rate Chrome and Firefox clamp
to, is the only silent one. Move the threshold up so the two fast rates
that do carry sound stop advertising otherwise.
2026-08-20 11:36:39 +03:00
yusufipek b9e2006e34 feat(comments): carry a batch of screenshots on one comment
A comment held one image, and the paste handler took the first item off the
clipboard and dropped the rest. Reviewing a cut usually means several
screenshots about the same moment, which meant one comment per screenshot or
one screenshot and a paragraph describing the others. Editing a comment could
not attach anything at all: the edit box had no paste handler, no file picker
and no way to remove what was already there.

A comment now carries up to five images, in the composer, in a reply and in
the editor. One paste stages every image on the clipboard, the file picker
takes a multiple selection, and a drop lands on whichever editor is open. Over
the cap the extras are refused out loud rather than dropped quietly. A single
image still fills the width; several tile into a grid, and either opens full
screen on click.

The images move into their own table. `comments.imageUrl` stays and follows the
first of them, so a reader that has not been updated keeps working, and the
migration copies the existing attachments across so the new table is complete
from the first read. Every path that resolves a URL back to a comment now asks
the new table: R2 cleanup, the orphan sweep, the storage accounting and the
reference checks that decide whether an object can be deleted. Left on the old
column they would have treated images two through five as unreferenced and
swept them.

Detaching an image while editing only breaks the link. The file stays in R2 and
in the assets pane, which is where it is deleted from and where its bytes are
already billed.
2026-08-20 11:01:33 +03:00
yusufipek 7b7b1d21b2 feat(player): raise the playback speed ceiling off YouTube's limit
A single speed ladder fed both players, so the 2x cap that YouTube's
iframe API enforces also applied to Bunny and R2, which are plain <video>
elements the browser will play far faster. Pick the ladder per provider:
YouTube keeps 0.25x-2x, the native ones go up to 16x, where Chrome and
Firefox clamp playbackRate. The picker labels everything past 4x as
"no audio", since that is where the browsers stop pitch-correcting and
drop the audio track.
2026-08-20 10:50:42 +03:00
yusufipek 2bc07c47e6 fix(theme): stop the theme menu from crowding its own top edge
The menu had no inner padding, so the first row's icon sat flush against
the popover border and the whole box read as clipped under the header.
It now carries the same padding, offset and fixed width as the account
menu next to it, and the System row uses a lucide icon instead of a
colour emoji that broke the icon column's alignment.
2026-08-18 12:21:02 +03:00
yusufipek f9f08a021d fix(upload): give a full trial account the upgrade link where it reads the refusal
The video uploader threw the API error away and toasted the bare message, so
the one caller most likely to hit the trial storage ceiling was the one that
lost the way out of it. Route it through toastApiError, which keeps the error
code and attaches the action, and repeat the link in the queue row so it
survives the toast timing out.

The button now says Upgrade rather than See plans, matching the verb the
message itself uses.
2026-08-18 11:57:17 +03:00
yusufipek 6561e0817e feat(storage): point a full trial account at the upgrade rather than at nothing
Being told "storage limit exceeded" when the limit is the free trial's three
gigabytes is a dead end. The account is not full because it stores a lot; it is
capped because it has not subscribed, and deleting files buys back very little.

The refusal now says which ceiling it is and carries its own error code, so the
toast can offer a link to the billing settings on the trial ceiling and stay
quiet on the paid one, where subscribing changes nothing.

The code had to survive the trip to the toast, which meant the upload helpers
throwing something that carries it rather than a bare Error. Two places were
dropping the server's message on the floor entirely: adding a version reported
"Failed to initialize upload" whatever the server said, and every asset upload
in the pane rewrote its own failure text.
2026-08-18 11:42:30 +03:00
yusufipek e78911e850 fix(comments): say the account is full instead of blaming the comment
The attachment goes up before the comment does, so a full account fails on
the image and never reaches the comment at all. Both that failure and a
rejected comment came out as "Failed to add comment", which tells the
uploader to try again, and trying again is the one thing that cannot work
when there is no room left.

Both now read out what the server said. A network fault, which has no message
anybody wants to see, still falls back to the old line.
2026-08-18 11:07:25 +03:00
yusufipek 4ff801738c fix(uploads): count a Bunny upload from the moment it is admitted
A Bunny init asked the quota whether it could store zero bytes, which is a
question with only one answer. Nothing an upload was about to consume was
visible to the next request, so every init inside the same window read the
same total and every one of them passed, and an upload that could never
fit was only refused after it had been sent.

The client now declares the size up front. It is checked against the
account's remaining room before Bunny is asked for anything, and held as
a reservation the next init has to see. The declaration is a claim rather
than proof, so it is signed into the upload token: the same token already
binds the video id, which is what makes the reservation safe to release
on a caller's say-so, since releasing it costs them the video it belongs
to.

The declared size is then written onto the version or asset row and the
reservation is dropped in the same transaction, because Bunny reports no
size at all for a video until it has finished encoding it. On a half hour
of footage that is most of an hour during which the upload did not appear
on the uploader's own storage page and did not count against the next
upload. Per-video accounting now takes the larger of what Bunny reports
and what was declared, so the estimate stands in until the real figure
arrives and Bunny's wins once it does.

Two smaller things came out of the same reading. The asset route's
in-transaction fallback compared against the plan limit, so a caller
quoting a reservation that no longer existed was measured against 200 GiB
even on a trial worth three. And the guest branch reserves without being
able to release early, because a guest grant is bound to our video id and
the caller's network context rather than to the Bunny video, which would
let the reservation be dropped while the upload it stands for carried on.
2026-08-18 10:35:08 +03:00
yusufipek 4c0b18e5e4 feat(marketing): say no card where the trial is actually claimed
Three places still carried the old promise. The register form, which is where
the CTA lands and where the trial is granted, said nothing about it at all, so
the landing page's claim had to be taken on faith across a page load. The
comparison profile summary still described the trial without mentioning the
card. And the CtaLink comment quoted a button label that no longer exists.

The hero and closing CTAs now read 'Open dashboard' for a signed-in visitor.
That button already pointed at /dashboard for them, and offering a free trial
to someone who is mid-subscription reads as a bug. The pricing card keeps the
short label because it sits directly under 'No credit card' and the card
narrows to about 230px at the md breakpoint, where the long label would wrap
out of a fixed-height button.
2026-08-18 09:23:21 +03:00
yusufipk 39e81042bb feat(billing): let people try the product before handing over a card
The trial now starts inside the product, at email verification, and Stripe
grants none at all: checkout creates a subscription that bills immediately.
Verifying an address is what buys the seven days, which is also the cheapest
abuse control there is.

An unexpired trial is treated as an entitlement the account already holds, so a
Stripe sync can add access but never retracts a trial that has not run out. That
matters most for the abandoned checkout: the resulting incomplete subscription
carries no trial_end, and writing it through would have erased the days the
account still had and locked it out.

Unpaid accounts are bounded by what they can cost us rather than by what they
can do: one workspace, one project, 3 GiB of direct uploads. YouTube imports,
share links, guests, comments and approvals stay unlimited, because those are
the parts worth trying and they cost nothing. isPaidTier() is the new seam;
hasBillingAccess() answers a different question now that access no longer
implies a card.

Signup CTAs, the pricing card, the comparison pages, the terms and the refund
policy all said the trial converts to a paid plan by itself. It no longer does,
so they say what happens instead. Settings and a banner name both dates that
matter: when the trial ends, and the fifteen days after that during which
nothing is deleted.

/admin/growth compares the two funnels on signup to paid within a fixed 30 day
window, not trial to paid. Dropping the card requirement multiplies trials, so
the old ratio can fall while more people actually pay, and reading it that way
would retire the change for the wrong reason.
2026-08-05 19:40:36 +03:00
Yusuf İpek b8d68a9196 Merge pull request #50 from yusufipk/feat/acquisition-analytics
feat(analytics): record where paying customers actually came from
2026-08-05 18:47:51 +03:00
yusufipk e3676e6142 docs(legal): print the full US business address on the site
Stripe flagged the account under Visa 1.5.1.2 and Mastercard 5.5/5.6:
the location stated on the site has to match the Stripe account. The
legal pages only said "Wyoming, United States" and the footer said
nothing at all. Both now carry the same address Stripe has on file.
2026-08-02 13:47:44 +03:00
yusufipk 7ca5abd041 feat(analytics): record where paying customers actually came from
Adds first-party acquisition attribution and a sixteen-event funnel, written to
this deployment's own database and read back on /admin/growth. Nothing is sent
anywhere else, and the whole subsystem is off unless OPENFRAME_ENABLE_ANALYTICS
is set, so a self-hosted instance carries the tables empty and pays nothing.

The proxy gives a visitor an anonymous id and stores what brought them in two
first-party cookies; signup copies that onto the account and claims the events
the visitor produced before they had one, which is what joins the two halves of
the funnel. Recording happens where each step actually happens rather than in
the browser: an ad blocker cannot undercount landing views, and blocking rates
differ by channel, so an undercounted denominator would have made GitHub traffic
look like it converts better than it does.

Every event carries a dedupe key on a UNIQUE column, so "recorded exactly once"
is a property of the schema rather than of fifteen call sites. Subscription
events are derived by comparing the row being overwritten with the row being
written inside the existing Stripe sync, which makes them order-independent and
replay-safe.

The scoreboard reports step-to-step conversion with the denominator beside it,
and splits by source over a rolling 28-day window rather than a week: at this
volume a weekly per-source cell holds single digits, and a percentage computed
from three visits reads exactly as confidently as one computed from three
hundred.

"How did you hear about us?" is asked on the first onboarding screen, not on the
registration form. The number being measured is the signup conversion rate, and
a question added to that form would move it.
2026-08-01 20:00:27 +03:00
yusufipk e7008c571a fix(voice): record the real length and write it into the file
The recording clock counted setInterval ticks, which a background tab
throttles away: a recording that kept going looked frozen at 13 seconds
and was saved with that length. It now reads the wall clock instead.

MediaRecorder also writes WebM with no usable duration. Chrome omits the
element entirely, Firefox reserves a Duration of 0.0 it never fills in,
so players had no length to show and played past the end of the seek bar.
lib/webm-duration.ts stamps the recorded length into Segment > Info when
the recording stops, in place where the browser reserved room for it.
2026-07-30 23:13:04 +07:00
yusufipk b51e690062 fix: close the findings the test suite surfaced
The suite that landed in #43/#44 was written against existing behaviour, so a
number of tests pinned bugs rather than asserting correct behaviour. This fixes
the production code and moves each of those tests onto the fixed behaviour in
the same change.

Security:

- project-download: derive the archive entry extension from the last path
  segment and restrict it to a short alphanumeric run, so an extensionless
  allowlisted url can no longer contribute a path separator; validate the r2
  branch against the strict proxy-path pattern instead of a `startsWith`, which
  let `/api/upload/video/clip.mp4/../../etc/passwd` through verbatim.
- rate-limit: hash a key or action wider than its column instead of skipping the
  query. Both the guard and the failing INSERT used to answer "allowed", so the
  limit stopped applying entirely. Warn at startup when TRUSTED_PROXY_MODE is
  unset in production.
- video uploads: the file name decides the content type; a client-declared video
  mime no longer makes `payload.exe` acceptable.
- email templates: escape in the helpers rather than relying on every caller,
  with an explicit `rawEmailHtml()` opt-out for the one call site that builds
  markup. `escapeHtml` now covers the single quote.
- CSP: allow loopback object storage outside production only.
- route-access: reach the billing redirect only for the workspace owner. Keying
  it off the owner's billing status alone made the redirect target an oracle for
  whose subscription had lapsed, and sent members to a page they cannot act on.
- search: carry the same billing condition every other read path carries.
- logger: check `err.name` as well as `err.constructor.name`, so a re-thrown,
  deserialised or minified Prisma error is still redacted.
- upload tokens: resolve the signing secret outside the try, so a server booted
  without one fails loudly instead of reporting every grant as a forgery.
- invitations: never downgrade an existing membership, and report a scoped
  invitation that points at nothing as not_found rather than accepted.
- auth: resolve the workspace role for every signed-in caller, so
  checkProjectAccess and computeProjectAccess stop disagreeing about the owner
  who also owns the workspace. The `intent` option is gone with it.
- r2-media-proxy: validate the object key inside the proxy so the guard travels
  with the function; delete the unused, unanchored `mediaUrlToR2Key`.
- r2: sign the content type into presigned PUT grants.

Correctness:

- frame rate snapping picks the nearest standard, not the first within
  tolerance, so 24, 30 and 60 fps are reachable at all.
- a version upload registers its Bunny cleanup as soon as bunny-init answers, so
  a failed tus upload no longer leaves a billed video behind.
- deleting videos clears storage before the rows, so a refused DELETE leaves a
  retryable row rather than an orphaned object.
- an expired upload session can be cancelled, which is what releases its quota.
- `voice/` joins the delete allowlist, so a voice note can be removed by the
  module that wrote it.
- a failed CORS write propagates instead of being mistaken for an empty config
  and replacing the bucket's rules.
- filtering projects by workspace no longer hides projects the unfiltered call
  returns.
- upload retries skip aborts and permanent 4xx; progress no longer divides by
  zero.
- reply edits no longer clear the comment's tag; optimistic resolve rolls back
  to the state it replaced; the delete snapshot is captured once.
- assorted UI fixes: duplicate React keys, double-click guards reading stale
  closures, the tag list fetched twice per load, a failed member list rendering
  as an empty one, a stale "Initializing upload..." beside a failure, and a
  registration banner pointing at an email that never arrives.

Consistency and access:

- the two download routes answer 404 for an id belonging to another tenant, as
  the comment export route already did. A caller who does belong still gets 403.
- accessible names for the share-link password field, the guest name gates, the
  version dialog inputs and the comment-tag controls.

Repository health:

- the runner image installs production dependencies only.
- a setup file for the unit project restores stubbed env centrally.
- native tsconfig path resolution replaces vite-tsconfig-paths.
- `uploadBytesWithProgress` exists once.
- admin stats bill Bunny storage to the workspace owner like every other
  quota, gate on the configured flag, wire up the single-flight guard and count
  the statuses that belonged to no bucket.
- `r2Client.destroy()` releases the presign client too.
- `prepare` tolerates a production install, where husky is absent.
2026-07-26 18:53:54 +07:00
yusufipk 1d099c68f2 test: add unit, API, component and end-to-end test suites
The repo had no automated tests. Every change was verified by hand.

Adds four layers, 2023 tests in total, runnable with one command:

- 1191 unit tests over the pure logic in lib/, including the full
  computeProjectAccess permission matrix and the billing gate
- 167 component and hook tests in jsdom, covering the hooks that hold
  real logic rather than presentational wrappers
- 647 API integration tests against a real Postgres, with only auth()
  mocked, including a data-driven sweep asserting that none of the 60
  route modules answers 2xx to an unauthenticated caller
- 18 Playwright specs driving a real browser against a real build

Infrastructure: vitest.config.ts with three projects, a disposable
Postgres and MinIO in docker-compose.test.yml, factories and helpers
under tests/, scripts/test.sh as the single entry point, a pre-push
hook running bun run verify, and CI split into check, test and e2e jobs.

The test database is built with prisma db push plus a replay of the
hand-written SQL, because prisma migrate deploy cannot build this schema
from empty: the migration history has no captured baseline. This mirrors
what scripts/docker-db-bootstrap.ts already does in production, and
tests/setup/db-global.ts carries a drift guard so a new migration fails
the run until someone reviews it.

Production code is unchanged apart from one pure-function extraction out
of use-video-player.ts, which was too large to test in jsdom.

Several tests pin behaviour that looks wrong, each marked KNOWN BUG in
place. TESTING.md section 12 records where the plan turned out to be
wrong, and AGENTS.md now states which layer a change needs a test in.
2026-07-26 11:17:26 +07:00
Yusuf İpek 58910a5f5a Merge pull request #39 from yusufipk/worktree-frame-counter
feat(player): add frame counter when scrubbing and seeking
2026-07-25 13:10:11 +03:00
yusufipk b23f3de666 feat(player): add frame counter when scrubbing and seeking
Show a timecode + frame readout above the timeline while dragging the
playhead, and flash it for a moment on keyboard/button seeks so frame
stepping is visible too.

Position and text are written from the existing rAF/DOM path that drives
the playhead, so the readout stays smooth without extra React renders.

Two supporting fixes the count depends on:

- Seed the frame rate from the HLS manifest FRAME-RATE attribute so a
  frame number is available before playback ever starts; previously the
  rate was only ever measured from requestVideoFrameCallback and stayed
  null until the video had played.
- Snap the measured rate to the nearest broadcast standard and skip
  samples taken mid-seek. A drifting float slid the count by whole
  frames late in a long video, and re-publishing a slightly different
  float on every presented frame forced a re-render per video frame.
2026-07-25 17:07:34 +07:00
Yusuf İpek 81285681dc Merge pull request #37 from yusufipk/worktree-fix-download-notice
feat(downloads): let the download progress toast be minimized
2026-07-25 12:59:18 +03:00
yusufipk 481728b93d feat(downloads): let the download progress toast be minimized
The download progress toast sits in the bottom-right corner on top of the
comment composer, blocking the voice-recording button and the comment box for
the whole duration of a download.

Render it through toast.custom so it can be collapsed to a small pill (percent
+ spinner) and expanded again while the download keeps running. The minimized
choice sticks for the rest of the session. The sonner <li> is click-through, so
only the panel itself covers the controls underneath.

Also dismiss the panel on failure — it had duration: Infinity and used to stay
on screen forever after an error.
2026-07-25 16:56:29 +07:00
yusufipk 2bad0a249f refactor(video): share R2 playback URL resolution and guard drift resync
- move resolveR2PlaybackUrl into lib/video-upload-validation.ts so the compare
  view and the main video page cannot drift apart
- validate the resolved URL with isPlayableVideoUrl before it reaches <video src>
- add a per-player cooldown so a follower that cannot keep up is not seeked
  every second, which would stutter rather than correct
2026-07-25 16:48:39 +07:00
yusufipk 33008d33ad style: fix prettier formatting 2026-07-12 18:38:17 +07:00
yusufipk 6c6df3cf1d feat: hosted-first landing + accurate fair source licensing copy
- Replace unsubstantiated hero claim with client sign-off messaging
- Rename Open Source (Self-hosted) to Fair Source (Self-hosted) with FSL
  explanation (source visible, self-hostable, Apache 2.0 after two years)
- Put Hosted Cloud first in pricing with Recommended badge and trial note
- Move self-hosting hero link to GitHub, out of primary CTA path
- Add FAQ entries for FSL licensing and 7-day free trial
- Align license language across landing, README, terms, SEO, comparisons
- Fix contact email to [email protected] everywhere (mailto links,
  notification sender fallbacks) and SEO fallback domain
2026-07-12 18:32:38 +07:00
yusufipk 5821f73d38 feat: show live progress while downloading named files
Bunny/cross-origin downloads are fetched into a blob before saving, which
on large files or slow connections looked stuck (spinner only). Stream the
body through a counting transform and show real byte progress in a toast:
per-file percent for single downloads and file N/M + percent for bulk.

- Progress is measured from Content-Length + received bytes (not estimated).
- The blob is assembled by the browser from the stream (can be disk-backed),
  so we don't accumulate chunks in the JS heap.
- Only the blob path shows a toast; same-origin (R2/S3/MinIO) and the >10 GB
  fallback use the browser's native download UI.
2026-07-10 22:27:53 +07:00
yusufipk 8845c2c643 feat: name video downloads by title + version
Downloads now save as "<video title> <version label>" (or "<title> vN"
when no label), with the real extension derived from the file's content
type, instead of the CDN's generic "original" name.

- Bunny (cross-origin CDN redirect) files are fetched and saved as a named
  blob, but only up to 10 GB; larger files fall back to a plain navigation
  so the browser streams to disk without buffering in memory.
- R2 / S3 / MinIO uploads are same-origin (/api/upload/video/...), so the
  download attribute names them correctly at any size, no buffering.
- Applies to both single-video and bulk/project downloads; bulk downloads
  run sequentially so at most one file is buffered at a time.
- Shared helper in lib/client/download-file.ts.
2026-07-10 21:58:09 +07:00
yusufipk bede216081 perf: smooth playhead + live scrubbing preview
Drive the timeline progress fill and playhead directly via a
requestAnimationFrame loop (bypassing React state) so the playhead glides
at the display refresh rate during playback instead of stepping ~4x/sec.

Scrubbing now previews frames live like an editor: while dragging, the
video is seeked with coalescing (one seek in flight, chasing the latest
target) so HLS stays responsive without stale-seek pileup. Playback pauses
during a scrub and resumes on release. Dragging tracks the cursor anywhere
on the page via window listeners.
2026-07-10 21:31:36 +07:00
yusufipk 57c5a127d1 feat: move videos to another project (single + bulk)
Add a "Move to project" action in the video card dropdown and the
selection-mode toolbar. Videos (with their versions, comments, assets and
video-scoped share links) can be moved into another project in the same
workspace.

- New GET/POST /api/projects/[projectId]/videos/move: GET lists manageable
  destination projects in the workspace; POST performs the move.
- Requires canEdit on both source and destination; same-workspace only.
- Move runs in an interactive transaction that re-asserts source ownership
  atomically (updateMany guarded by projectId) to avoid a TOCTOU race, and
  returns 409 on conflict. GET is rate-limited ('api').
2026-07-10 20:55:12 +07:00
yusufipk d500dcb042 fix: preserve newlines/paragraph breaks in rendered comments
Comment content was stored with newlines intact but rendered inside <p>
elements with default white-space, collapsing line breaks into single
spaces. Add whitespace-pre-wrap (and break-words) to the comment/reply
render wrappers in the comments pane and the compare-versions view.
2026-07-10 19:33:03 +07:00
yusufipk 52e4169db2 feat: add project bulk download and bulk video delete
Add a "Download project" / "Download selected" flow that builds a
server-side manifest of downloadable media, plus a selection mode with
bulk delete for project videos.

Gate viewer downloads behind a new project allowDownloads setting
(default off, opt-in). Admins can always download; enabling on a public
project allows anonymous visitors to download. Enforce the setting on
every download surface (manifest, version, asset, watch, video routes)
via canDownloadProjectMedia.

Add rate limits for the manifest endpoint, host allowlisting for direct
download URLs, and configurable file/byte caps.

Closes #16
Closes #19
2026-06-27 13:24:05 +02:00
yusufipk 51257e004f Add SEO comparison landing pages and footer compare links.
Introduces dynamic marketing comparison routes, competitor data, and Compare sections on the homepage and marketing footer.
2026-06-14 16:36:26 +02:00
yusufipk 00124bc7c2 feat: bulk video uploads and S3 asset video support (#18)
Add multi-file drag-and-drop queues for project videos and the assets pane, and route asset video uploads through S3/R2 when direct Bunny uploads are disabled.
2026-06-13 23:24:21 +02:00
yusufipk 4bf6e821af feat: enable S3 video uploads and update related configurations
- Added support for self-hosted S3 video uploads with new environment variables: OPENFRAME_ENABLE_S3_VIDEO_UPLOADS and OPENFRAME_MAX_VIDEO_UPLOAD_BYTES.
- Updated .env.example and .env.docker.example to reflect new configuration options.
- Enhanced Content Security Policy to include origins for S3-compatible storage.
- Updated dependencies for AWS SDK to support new features.
- Refactored upload logic to accommodate both Bunny and S3 upload providers.
- Updated documentation to clarify the usage of direct uploads and S3 configurations.
- Closes #11
2026-05-27 17:04:39 +02:00