Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ For any other update, first run `postplan read <space/site> --pull <dir>` and de

## Capabilities

- Deploy a single file or a folder with HTML, Markdown, images, PDFs, fonts, and audio.
- Markdown supports native Mermaid fences and `index.md` landing pages. Standalone images, including AVIF, have fit, zoom, original and download controls.
- Deploy a single file or a folder with HTML, Markdown, images, PDFs, fonts, audio, and video.
- Markdown supports native Mermaid fences and `index.md` landing pages. Standalone images, including AVIF, have fit, zoom, original and download controls. Standalone videos (mp4, m4v, mov, webm) open in a native player.
- Use `unlisted`, `private`, `members`, or `team` visibility.
- Pull exact source with `postplan read <space/site> --pull <dir>` and safely redeploy it.
- Read anchored comments, reply to threads, and check notifications from the terminal.
Expand Down
12 changes: 12 additions & 0 deletions packages/api/src/__tests__/content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,18 @@ describe('audio serving: MIME resolution + HTTP Range support', () => {
expect(res.headers.get('accept-ranges')).toBe('bytes')
})

test('a CLI-uploaded mp4 serves as video/mp4 and honors byte ranges', async () => {
const { app, db, r2, env } = setup()
const { token } = await seedGatedFile(db, r2, 'explainer.mp4', BODY)
const full = await app.request(`/_t/${token}/sam/site/explainer.mp4`, {}, env)
expect(full.headers.get('content-type')).toBe('video/mp4')
expect(full.headers.get('accept-ranges')).toBe('bytes')
const part = await app.request(`/_t/${token}/sam/site/explainer.mp4`, { headers: { range: 'bytes=2-5' } }, env)
expect(part.status).toBe(206)
expect(part.headers.get('content-range')).toBe(`bytes 2-5/${BODY.length}`)
expect(await part.text()).toBe('2345')
})

test('bytes=0-1 → 206 with the first two bytes and a correct Content-Range', async () => {
const { app, db, r2, env } = setup()
const { token } = await seedGatedFile(db, r2, 'song.mp3', BODY)
Expand Down
4 changes: 2 additions & 2 deletions packages/api/src/agent-docs.ts

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions packages/api/src/lib/__tests__/mime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ describe('contentType — full MIME table (W0-4 characterization)', () => {
expect(contentType('song.flac', null)).toBe('audio/flac')
expect(contentType('song.aac', null)).toBe('audio/aac')
})
test('video extensions resolve to their video MIME regardless of stored type', () => {
expect(contentType('explainer.mp4', 'application/octet-stream')).toBe('video/mp4')
expect(contentType('CLIP.M4V', null)).toBe('video/mp4')
expect(contentType('take.mov', null)).toBe('video/quicktime')
})
test('video extensions stay out of the audio set', () => {
for (const ext of ['mp4', 'm4v', 'mov']) expect(AUDIO_EXTENSIONS.has(ext)).toBe(false)
})
})

describe('audioExtFromPart — voice-upload part → audio extension', () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/lib/mime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export const EXT_MIME: Record<string, string> = {
aac: 'audio/aac',
// MediaRecorder's default container in Chromium/Firefox; served (and comment-voice stored) as audio.
webm: 'audio/webm',
mp4: 'video/mp4',
m4v: 'video/mp4',
mov: 'video/quicktime',
}

/** Extensions the worker serves as audio — derived from EXT_MIME so it can't drift. */
Expand Down
4 changes: 2 additions & 2 deletions packages/api/src/routes/__tests__/whats-new.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ describe('C7 route.get.exactJSON — exact ordered items, unreadCount, throughDa
// Whole-response deep-equal, not key-presence: a dropped item field or an extra top-level key fails.
expect(await res.json()).toEqual({
items: JSON.parse(JSON.stringify(RELEASES)),
unreadCount: 14,
unreadCount: 15,
throughDate: NEWEST_RELEASE_DATE,
})
})
Expand Down Expand Up @@ -173,6 +173,6 @@ describe('B3 relogin.noReset — the existing-user branch must not clear an exis
)
expect(await getWatermark(db, first.id)).toBe(before as string)
const res = await app.request('/api/whats-new', { headers: await mintBearer(kv, first.id) }, env)
expect(((await res.json()) as { unreadCount: number }).unreadCount).toBe(14)
expect(((await res.json()) as { unreadCount: number }).unreadCount).toBe(15)
})
})
9 changes: 8 additions & 1 deletion packages/api/src/whats-new/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
import type { Release } from './bake'

export const RELEASES: Release[] = [
{
"slug": "video",
"title": "Host and play video",
"date": "2026-10-09T12:00:00.000Z",
"featured": false,
"bodyHtml": "<p>Deploy an mp4, m4v, mov, or webm file and Postplan opens it in a video player.</p>\n<ul>\n<li>Seek anywhere without downloading the whole file</li>\n<li>Open the original or download it from the toolbar</li>\n<li>Comments on a video can insert the current <code>[m:ss]</code> timestamp, like audio</li>\n</ul>\n<p>Files are still limited to 20 MB each.</p>\n"
},
{
"slug": "review-and-cli",
"title": "Easier reviews and a more reliable CLI",
Expand Down Expand Up @@ -123,4 +130,4 @@ export const RELEASES: Release[] = [
}
]

export const NEWEST_RELEASE_DATE: string | null = "2026-10-06T16:30:00.000Z"
export const NEWEST_RELEASE_DATE: string | null = "2026-10-09T12:00:00.000Z"
13 changes: 13 additions & 0 deletions packages/api/src/whats-new/notes/2026-10-09-video.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
title: Host and play video
slug: video
date: 2026-10-09T12:00:00.000Z
featured: false
---
Deploy an mp4, m4v, mov, or webm file and Postplan opens it in a video player.

- Seek anywhere without downloading the whole file
- Open the original or download it from the toolbar
- Comments on a video can insert the current `[m:ss]` timestamp, like audio

Files are still limited to 20 MB each.
9 changes: 6 additions & 3 deletions packages/cli/internal/cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ postplan deploy report.html # → /<you>/report
postplan deploy ./dist --space docs --name api-reference --visibility members
postplan deploy ./call-feedback # folder w/ index.html + audio/*.wav → a page with players
postplan deploy clip.mp3 # a single audio file → a playable audio page
postplan deploy explainer.mp4 # a single video file → a playable video page
```

### Markdown
Expand All @@ -111,16 +112,18 @@ Deploy a Markdown file directly, or use `index.md` as a folder landing page (`in

Fenced `mermaid` blocks render automatically with a bundled renderer and click-to-enlarge. No CDN or HTML wrapper is needed. Invalid diagrams retain their source. Raw HTML, user scripts, and remote images remain blocked in Markdown.

### Hosting media (audio, images, PDFs, fonts)
A site is just its files, so any static asset in the folder is hosted and served with the correct type — images (png/jpg/jpeg/gif/webp/avif/svg/ico), PDFs, fonts, and **audio** (mp3, wav, m4a, ogg, flac, aac, webm). Reference them from your HTML with **relative paths** and they resolve against the page:
### Hosting media (audio, video, images, PDFs, fonts)
A site is just its files, so any static asset in the folder is hosted and served with the correct type — images (png/jpg/jpeg/gif/webp/avif/svg/ico), PDFs, fonts, **audio** (mp3, wav, m4a, ogg, flac, aac, webm), and **video** (mp4, m4v, mov, webm). Reference them from your HTML with **relative paths** and they resolve against the page:

```html
<audio src="audio/clip.mp3" controls></audio>
<video src="explainer.mp4" controls playsinline></video>
<img src="pic.png">
```

- **Audio streams with seeking** — audio is served with HTTP byte-range support, so `<audio>` players can scrub and play without downloading the whole file first.
- **Audio and video stream with seeking** — media is served with HTTP byte-range support, so `<audio>` and `<video>` players can scrub and play without downloading the whole file first.
- **A lone audio file is playable on its own** — `postplan deploy clip.mp3` (no HTML) opens as a real audio player.
- **A lone video file is playable on its own** — `postplan deploy explainer.mp4` opens in a video player with open-original and download controls. Prefer H.264 mp4 for the widest browser support.
- Standalone images open in a viewer with fit-to-screen, zoom, open-original and download controls. Image bytes are unchanged.
- **Limits**: 20 MB per file, 200 files and 100 MB total per deploy.

Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/components/review/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function Composer({
// pending anchor here so every select/pinpoint puts the caret back in the box.
focusOn?: unknown
className?: string
// Audio view only: inserts a `[m:ss] ` prefix for the player's current position. `getPrefix`
// Audio/video views only: inserts a `[m:ss] ` prefix for the player's current position. `getPrefix`
// is called at click time (not render time) so it always reflects the latest playback position.
timestampButton?: { label: string; getPrefix: () => string }
// Lazily fetch the users this composer may @-mention (called once, on the first `@`). Absent →
Expand Down
5 changes: 2 additions & 3 deletions packages/web/src/components/review/ReviewRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,9 +317,8 @@ export function ReviewRail({
</div>

<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-4 pb-4 [scrollbar-gutter:stable]">
{/* Both creation paths get named (#112). Keyed on getCurrentTime, which is the one prop
that is still genuinely audio-only — the audio view has no DOM to select text in, so
offering it a "select text" path would be a lie. */}
{/* Both creation paths get named (#112). Keyed on getCurrentTime, set only for audio and
video — players have no DOM to select text in, so offering a "select text" path would be a lie. */}
{active.length === 0 && !composing && (
<p className="px-1 py-8 text-center text-muted-foreground text-sm">
{filter !== 'open'
Expand Down
21 changes: 3 additions & 18 deletions packages/web/src/components/viewer/ImageView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { MediaToolbar } from './MediaToolbar'

interface IImageView {
src: string
Expand All @@ -11,16 +12,10 @@ export function ImageView(props: IImageView) {
const { src, fileName } = props
const [zoom, setZoom] = useState(1)
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const downloadUrl = new URL(src)
downloadUrl.searchParams.set('download', '1')

return (
<div className="flex size-full flex-col">
<div
className="flex flex-wrap items-center justify-center gap-2 border-b p-2"
role="toolbar"
aria-label="Image controls"
>
<MediaToolbar src={src} fileName={fileName} label="Image controls">
<Button
variant="outline"
size="sm"
Expand All @@ -45,17 +40,7 @@ export function ImageView(props: IImageView) {
<Button variant="outline" size="sm" onClick={() => setZoom(1)}>
Fit to screen
</Button>
<Button variant="outline" size="sm" asChild>
<a href={src} target="_blank" rel="noreferrer">
Open original
</a>
</Button>
<Button variant="outline" size="sm" asChild>
<a href={downloadUrl.href} download={fileName}>
Download
</a>
</Button>
</div>
</MediaToolbar>
<section className="relative min-h-0 flex-1 overflow-auto" aria-label="Image preview">
{status === 'loading' && (
<p className="absolute inset-x-0 p-4 text-center text-sm" role="status">
Expand Down
33 changes: 33 additions & 0 deletions packages/web/src/components/viewer/MediaPane.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ReactElement, RefObject } from 'react'
import { Spinner } from '@/components/ui/states'
import type { MediaKind } from '@/hooks/useMediaKind'
import { AudioView } from './AudioView'
import { ImageView } from './ImageView'
import { VideoView } from './VideoView'

interface IMediaPane {
kind: Exclude<MediaKind, 'document'>
src: string
fileName: string
audioRef: RefObject<HTMLAudioElement | null>
videoRef: RefObject<HTMLVideoElement | null>
}

// fallow-ignore-next-line private-type-leak -- Component props are intentionally module-private.
export function MediaPane(props: IMediaPane): ReactElement {
const { kind, src, fileName, audioRef, videoRef } = props
switch (kind) {
case 'probing':
return (
<div className="flex size-full items-center justify-center">
<Spinner className="size-6" />
</div>
)
case 'audio':
return <AudioView src={src} fileName={fileName} audioRef={audioRef} />
case 'video':
return <VideoView src={src} fileName={fileName} videoRef={videoRef} />
case 'image':
return <ImageView src={src} fileName={fileName} />
}
}
32 changes: 32 additions & 0 deletions packages/web/src/components/viewer/MediaToolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'

interface IMediaToolbar {
src: string
fileName: string
label: string
children?: ReactNode
}

// fallow-ignore-next-line private-type-leak -- Component props are intentionally module-private.
export function MediaToolbar(props: IMediaToolbar) {
const { src, fileName, label, children } = props
const downloadUrl = new URL(src)
downloadUrl.searchParams.set('download', '1')

return (
<div className="flex flex-wrap items-center justify-center gap-2 border-b p-2" role="toolbar" aria-label={label}>
{children}
<Button variant="outline" size="sm" asChild>
<a href={src} target="_blank" rel="noreferrer">
Open original
</a>
</Button>
<Button variant="outline" size="sm" asChild>
<a href={downloadUrl.href} download={fileName}>
Download
</a>
</Button>
</div>
)
}
39 changes: 39 additions & 0 deletions packages/web/src/components/viewer/VideoView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { type RefObject, useState } from 'react'
import { MediaToolbar } from './MediaToolbar'

interface IVideoView {
src: string
fileName: string
videoRef: RefObject<HTMLVideoElement | null>
}

// fallow-ignore-next-line private-type-leak -- Component props are intentionally module-private.
export function VideoView(props: IVideoView) {
const { src, fileName, videoRef } = props
const [failed, setFailed] = useState(false)

return (
<div className="flex size-full flex-col">
<MediaToolbar src={src} fileName={fileName} label="Video controls" />
<section className="flex min-h-0 flex-1 items-center justify-center p-4" aria-label="Video preview">
{failed ? (
<p className="text-center text-sm" role="alert">
Could not play this video here. Try opening the original or downloading it.
</p>
) : (
// biome-ignore lint/a11y/useMediaCaption: uploaded files carry no caption track to point at.
<video
ref={videoRef}
src={src}
title={fileName}
controls
playsInline
preload="metadata"
className="block max-h-full max-w-full bg-black"
onError={() => setFailed(true)}
/>
)}
</section>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { expect, test } from 'bun:test'
import { fireEvent, render, screen } from '@testing-library/react'
import { ImageView } from '../ImageView'
import { isImageFile } from '@/lib/image'
import { fileKindOf } from '@/lib/media'

test('image controls zoom, reset, and preserve the original protected URL', () => {
render(<ImageView src="https://content.test/_t/token/sp/site/picture.avif" fileName="picture.avif" />)
Expand Down Expand Up @@ -29,6 +29,6 @@ test('broken images show a useful error', () => {

test('image detection covers supported formats without treating HTML as an image', () => {
for (const ext of ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'ico'])
expect(isImageFile(`PHOTO.${ext.toUpperCase()}`)).toBe(true)
expect(isImageFile('photo.png.html')).toBe(false)
expect(fileKindOf(`PHOTO.${ext.toUpperCase()}`)).toBe('image')
expect(fileKindOf('photo.png.html')).toBe('document')
})
26 changes: 26 additions & 0 deletions packages/web/src/components/viewer/__tests__/VideoView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { expect, test } from 'bun:test'
import { createRef } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { VideoView } from '../VideoView'

const SRC = 'https://content.test/_t/token/sp/site/explainer.mp4'

test('video renders a native player with original and download links', () => {
const videoRef = createRef<HTMLVideoElement>()
const { container } = render(<VideoView src={SRC} fileName="explainer.mp4" videoRef={videoRef} />)
const video = container.querySelector('video')
expect(video?.getAttribute('src')).toBe(SRC)
expect(video?.hasAttribute('controls')).toBe(true)
expect(video?.hasAttribute('playsinline')).toBe(true)
expect(video?.getAttribute('preload')).toBe('metadata')
expect(videoRef.current).toBe(video)
expect(screen.getByRole('link', { name: 'Open original' }).getAttribute('href')).toBe(SRC)
expect(screen.getByRole('link', { name: 'Download' }).getAttribute('href')).toBe(`${SRC}?download=1`)
})

test('unplayable video shows a useful error', () => {
const { container } = render(<VideoView src={SRC} fileName="explainer.mp4" videoRef={createRef()} />)
fireEvent.error(container.querySelector('video') as HTMLVideoElement)
expect(screen.getByRole('alert').textContent).toContain('Could not play')
expect(screen.getByRole('link', { name: 'Download' })).toBeTruthy()
})
Loading
Loading