VideoCapture
Records a short clip through the camera with a limit and a timer ring, shows it before it is accepted, and falls back to the system's camera where recording is refused.
Demo
No clip yet.
The browser asks first. If it is refused, the same field opens the phone's own camera.
Get it
It needs theme.qwe, as every component does, and button.qwe; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/button.qwe$ curl -O https://quillolang.org/components/ui/video_capture.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/video_capture.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/video_capture.qwe
component VideoCaptureDemoLive() { state got = null state shown = true <div.demo-stack> {shown && <form.demo-stack method="post" action="/" enctype="multipart/form-data"> <VideoCapture name="clip" limit={2} hint="Two seconds in this example." on_change={(c) => { got = c }}/> </form>} <p.demo-note id="video-capture-demo-note">{got == null ? "No clip yet." : `Accepted: ${got.name}, ${got.type}, ${got.seconds == null ? "length not known" : `${round(got.seconds)} s`}.`}</p> <Button size="lg" variant="ghost" tone="neutral" on_click={() => { shown = !shown }}>{shown ? "Leave the page" : "Come back"}</Button> </div>}view VideoCaptureDemo() = <div.demo-stack> <VideoCaptureDemoLive/> <p.demo-note>The browser asks first. If it is refused, the same field opens the phone's own camera.</p> </div>Source
ui/video_capture.qwe
// VideoCapture: records a short clip through the camera, with a limit that ends it, a ring// that shows how much is left, and a look at the clip before it is accepted. The clip is a// real file in a file field, so a plain multipart <form> sends it. Where recording cannot// happen, a person who said no, a page that may not reach the camera or a browser without// MediaRecorder, the same field becomes the system's own camera (`<input capture>`), which// needs no permission from the page.//// <form method="post" action="/clip" enctype="multipart/form-data">// <VideoCapture name="clip" limit={15} hint="Show the damage from each side."/>// <Button size="lg" type="submit">Send</Button>// </form>// <VideoCapture facing="user" sound={false} on_change={(c) => { clip = c }}/>//// name: the file field's name ("video"). label: what opens the camera ("Record a clip").// limit: the most seconds a clip may run (15; at least 1, at most 600). facing:// "environment" (the back camera, default) or "user". sound: records the microphone too// (true; a person who refuses it still gets a clip, without sound). bitrate: bits a second// for the picture (2500000). max_size: the most bytes a file from the fallback may have// (none). hint, field (the input's id) and class as for every field.// on_change(clip): inside one of your components, given { name, size, type, seconds } once// a clip is accepted, or null when it is taken away. `seconds` is null when a fallback// file's length cannot be read here.//// The camera is let go when the clip is done, when the app leaves the screen (a recording// under way ends and keeps what it has) and when the component leaves the page.//// Needs Button: `import ui/button.qwe use Button`.import ui/theme.qwe use ui_clsimport ui/button.qwe use Buttonlet video_capture_css = css`.ui-video-capture { container-type: inline-size; width: 100%; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-video-capture-body { display: grid; gap: 10px; }.ui-video-capture-frame { position: relative; display: grid; place-items: center; overflow: hidden; aspect-ratio: 3 / 4; max-height: 70dvh; width: 100%; margin: 0 auto; border-radius: var(--ui-radius); background: #111; color: #eee; text-align: center;}.ui-video-capture-frame video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }.ui-video-capture-frame video[hidden] { display: none; }.ui-video-capture-review { object-fit: contain !important; background: #000; }.ui-video-capture-msg { z-index: 1; max-width: 80%; padding: 16px; }.ui-video-capture-clock { position: absolute; top: 10px; left: 50%; z-index: 2; transform: translateX(-50%); padding: 3px 10px; border-radius: 999px; background: rgba(0, 0, 0, .55); color: #fff; font: 600 14px/1.4 var(--ui-font); font-variant-numeric: tabular-nums;}.ui-video-capture-go { position: absolute; bottom: 14px; left: 50%; z-index: 2; width: 84px; height: 84px; transform: translateX(-50%); }.ui-video-capture-ring { position: absolute; inset: 0; width: 100%; height: 100%; transform: rotate(-90deg); pointer-events: none; }.ui-video-capture-ring circle { fill: none; stroke-width: 6; }.ui-video-capture-ring-back { stroke: rgba(255, 255, 255, .4); }.ui-video-capture-ring-left { stroke: var(--ui-danger); }.ui-video-capture-shutter { position: absolute; inset: 10px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 50%; background: #fff; color: var(--ui-danger); cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: transparent;}.ui-video-capture-shutter:focus-visible { outline: 3px solid #fff; outline-offset: 2px; }.ui-video-capture-shutter span { display: block; width: 44px; height: 44px; border-radius: 50%; background: currentColor; transition: border-radius .15s, transform .15s; }.ui-video-capture-on .ui-video-capture-shutter span { border-radius: 8px; transform: scale(.6); }.ui-video-capture-side { display: grid; gap: 8px; align-content: start; }.ui-video-capture-bar { display: flex; flex-wrap: wrap; gap: 8px; }.ui-video-capture-bar > * { flex: 1 1 auto; }.ui-video-capture-file { position: relative; display: none; align-items: center; justify-content: center; min-height: 46px; padding: 0 22px; overflow: hidden; flex: 1 1 auto; border-radius: var(--ui-radius-sm); background: var(--ui-accent); color: var(--ui-accent-fg); font: 500 16px/1 var(--ui-font); cursor: pointer;}.ui-video-capture-file-on { display: inline-flex; }.ui-video-capture-file input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; font-size: 16px; cursor: pointer; }.ui-video-capture-file:has(input:focus-visible) { box-shadow: var(--ui-focus); }.ui-video-capture-note, .ui-video-capture-hint { margin: 0; color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-video-capture-why { margin: 0; color: var(--ui-danger); font-size: 13px; }/* A tablet, or a wide pane: the frame beside its controls, and upright clips stay upright. */@container (min-width: 440px) { .ui-video-capture-body { grid-template-columns: minmax(0, 1fr) 170px; align-items: start; gap: 16px; } .ui-video-capture-bar { flex-direction: column; } .ui-video-capture-bar > * { flex: none; }}@media (prefers-reduced-motion: reduce) { .ui-video-capture-shutter span { transition: none; } }`// What to record in, best first. avc3 leads because turning the phone changes the picture's// size in mid-recording: avc1, which plain "video/mp4" gives on Chrome, cannot carry that, and// avc3 can. Plain mp4 comes next for Safari, which names no codec, then WebM.let video_capture_kinds = ["video/mp4;codecs=avc3.42E01E,mp4a.40.2", "video/mp4", "video/webm;codecs=vp9,opus", "video/webm"]let video_capture_ring_len = 238.76 // 2 pi r, for the ring's r of 38// The limit a prop gives, kept to what can be recorded: a bad one is the default's 15.fn video_capture_limit(s: num) -> num = s <= 0 ? 15 : s < 1 ? 1 : s > 600 ? 600 : s// How much of the ring's stroke is hidden after t of limit seconds: none at the start, all at the end.fn video_capture_ring(t: num, limit: num) -> num { let part = limit <= 0 ? 1 : t / limit round(video_capture_ring_len * (part < 0 ? 0 : part > 1 ? 1 : part), 2)}// Seconds as m:ss, rounded down, so 14.9 is still 0:14.fn video_capture_clock(s: num) -> str { let t = s < 0 ? 0 : floor(s) let sec = t % 60 `${floor(t / 60)}:${sec < 10 ? "0" : ""}${sec}`}// A file's end from its type, with any `;codecs=` cut off.fn video_capture_ext(mime: str) -> str { let base = split(mime, ";")[0] match base { "video/mp4" => "mp4" "video/webm" => "webm" "video/quicktime" => "mov" _ => "video" }}// A length the browser gave: an unfinished file says Infinity or NaN, which is "not known".fn video_capture_seconds(d) { if d == null || is_err(d) || type_of(d) == "str" { return null } d > 0 && d < 86400 ? d : null}// What a refusal of the camera means: "refused" for a person or a policy, "none" for the rest.fn video_capture_why(name) -> str = name == "NotAllowedError" || name == "SecurityError" ? "refused" : "none"component VideoCapture(name: str = "video", label: str = "Record a clip", limit: num = 15, facing: str = "environment", sound: bool = true, bitrate: int = 2500000, max_size: int? = null, hint: str? = null, field: str? = null, on_change = null, class: str? = null) { style global video_capture_css state status = "idle" // idle, starting, live, recording, finishing, review, accepted, refused, none state stream = null state recorder = null state parts = [] state began = 0 state spent = 0 state clip = null // { url, blob, type, seconds, name, size } state screen = null // the live picture state input = null state said = "" state silent = false state settings = false // the app can open its page in Settings let most = video_capture_limit(limit) let id = field ?? `video-capture-${name}` let fallback = status == "refused" || status == "none" let release = () => { if stream != null { for t in js("Array.from", stream.getTracks()) { t.stop() } stream = null } if screen != null { screen.srcObject = null } } let free = () => { if clip != null { js("URL.revokeObjectURL", clip.url) clip = null } } let stop_recording = () => { if recorder != null && recorder.state != "inactive" { status = "finishing" recorder.stop() } } let lost = (words: str) => { if status == "recording" { said = words stop_recording() } else { if status == "live" || status == "starting" { release() said = words status = "idle" } } } if status == "recording" { every(100, () => { spent = (now() - began) / 1000 if spent >= most { stop_recording() } }) } let cannot = (kind: str, words: str) => { release() settings = kind == "refused" && js("window").quilloNative != null said = words status = kind } // The stream, or the name of what stopped it: an error value would not say which refusal it was. let ask = (wanted) => js("Promise.resolve", js_new("Promise", (done) => { done(js("navigator.mediaDevices.getUserMedia", wanted).then((s) => { stream: s, why: null }, (e) => { stream: null, why: e.name })) })) let open = () => { if status == "starting" { return null } said = "" silent = false let w = js("window") if js("navigator").mediaDevices == null { cannot("none", "This page cannot reach a camera. Use the phone's camera instead.") return null } if w.MediaRecorder == null { cannot("none", "This browser cannot record here. Use the phone's camera instead.") return null } status = "starting" let look = { facingMode: { ideal: facing }, width: { ideal: 1280 }, height: { ideal: 720 } } let got = ask({ video: look, audio: sound }) // Refused with sound: the camera alone may still be allowed. if got.stream == null && sound { got = ask({ video: look, audio: false }) silent = got.stream != null } if got.stream == null { let kind = video_capture_why(got.why) cannot(kind, kind == "refused" ? "The camera was not allowed. Allow it in Settings, or use the phone's camera instead." : "No camera could be opened here. Use the phone's camera instead.") return null } let s = got.stream stream = s let track = js("Array.from", s.getVideoTracks())[0] track.addEventListener("ended", (_e = null) => { lost("The camera stopped. Open it again to go on.") }) screen.srcObject = s status = "live" screen.play().catch((_ = null) => { said = "" }) } let finished = (mime: str) => { let kept = parts recorder = null parts = [] let seconds = spent > most ? most : spent release() let blob = js_new("Blob", js("Array.from", kept), { type: mime }) if blob.size == 0 { cannot("none", "Nothing was recorded. Use the phone's camera instead.") return null } let ext = video_capture_ext(mime) clip = { url: js("URL.createObjectURL", blob), blob, type: split(mime, ";")[0], seconds, name: `clip.${ext}`, size: blob.size } status = "review" } let record = () => { if stream == null { return null } let kinds = video_capture_kinds | filter((t) => js("MediaRecorder.isTypeSupported", t)) let mime = len(kinds) > 0 ? kinds[0] : "" // In a Promise, here and at the start: both throw, which would stop the handler, not give an error value. let r = js("Promise.resolve", js_new("Promise", (done) => { done(js_new("MediaRecorder", stream, mime != "" ? { mimeType: mime, videoBitsPerSecond: bitrate } : { videoBitsPerSecond: bitrate })) })) if is_err(r) { cannot("none", "This browser cannot record here. Use the phone's camera instead.") return null } parts = [] r.addEventListener("dataavailable", (e) => { if e.data.size > 0 { parts = parts | push(e.data) } }) r.addEventListener("stop", (_e = null) => { finished(r.mimeType != "" ? r.mimeType : mime != "" ? mime : "video/webm") }) r.addEventListener("error", (_e = null) => { recorder = null parts = [] cannot("none", "Recording stopped with an error. Use the phone's camera instead.") }) let going = js("Promise.resolve", js_new("Promise", (done) => { r.start(500) done(true) })) if is_err(going) { cannot("none", "This browser cannot record here. Use the phone's camera instead.") return null } recorder = r began = now() spent = 0 status = "recording" if js("window").quilloNative != null { js("quilloNative.haptic", "medium") } } let tell = () => { on_change && on_change({ name: clip.name, size: clip.size, type: clip.type, seconds: clip.seconds }) } let accept = () => { let dt = js_new("DataTransfer") dt.items.add(js_new("File", [clip.blob], clip.name, { type: clip.type })) input.files = dt.files status = "accepted" tell() } let again = () => { let had = status == "accepted" free() input.value = "" if had { on_change && on_change(null) } status = "idle" open() } // The system's camera, or a file: it has made the clip, so only its length is left to check. let take_file = (e) => { let file = e.target.files[0] if file == null { return null } free() if max_size != null && file.size > max_size { input.value = "" said = "That clip is too large to send." return null } let url = js("URL.createObjectURL", file) let probe = js("document.createElement", "video") probe.preload = "metadata" probe.muted = true let d = js("Promise.resolve", js_new("Promise", (done) => { probe.addEventListener("loadedmetadata", (_e = null) => { done(probe.duration) }) probe.addEventListener("error", (_e = null) => { done(null) }) probe.src = url })) let seconds = video_capture_seconds(d) if seconds != null && seconds > most + 1 { js("URL.revokeObjectURL", url) input.value = "" said = `That clip runs ${video_capture_clock(seconds)}. The limit is ${video_capture_clock(most)}.` return null } said = "" clip = { url, blob: file, type: file.type ?? "video/mp4", seconds, name: file.name, size: file.size } status = "accepted" tell() } <div class={ui_cls(["ui-video-capture", status == "recording" && "ui-video-capture-on", class])} data-state={status} @mount={(_el) => { let d = js("document") let seen = (_e = null) => { if d.hidden { lost("The camera was closed while the app was away.") } } d.addEventListener("visibilitychange", seen) () => { d.removeEventListener("visibilitychange", seen) if recorder != null && recorder.state != "inactive" { recorder.stop() } release() free() } }}> <div.ui-video-capture-body> <div.ui-video-capture-frame> <video muted={true} playsinline={true} autoplay={true} hidden={status != "live" && status != "recording"} aria-label="The camera's picture" @mount={(el) => { screen = el }}></video> {(status == "review" || status == "accepted") && clip != null && <video.ui-video-capture-review src={clip.url} controls={true} playsinline={true} aria-label="The clip recorded"></video>} {(status == "idle" || status == "starting" || status == "finishing" || fallback) && <p.ui-video-capture-msg role="status"> {match status { "starting" => "Asking for the camera…" "finishing" => "Finishing the clip…" "refused" => "The camera is off." "none" => "No camera here." _ => `Up to ${video_capture_clock(most)}` }} </p>} {status == "recording" && <span.ui-video-capture-clock role="timer" aria-label="Time recorded">{video_capture_clock(spent)} / {video_capture_clock(most)}</span>} {(status == "live" || status == "recording") && <div.ui-video-capture-go> <svg.ui-video-capture-ring viewBox="0 0 84 84" aria-hidden="true"> <circle.ui-video-capture-ring-back cx="42" cy="42" r="38"/> <circle.ui-video-capture-ring-left cx="42" cy="42" r="38" stroke-dasharray={str(video_capture_ring_len)} stroke-dashoffset={str(video_capture_ring(spent, most))}/> </svg> <button.ui-video-capture-shutter type="button" aria-label={status == "recording" ? "Stop recording" : "Start recording"} @click={() => { if status == "recording" { stop_recording() } else { record() } }}><span></span></button> </div>} </div> <div.ui-video-capture-side> <div.ui-video-capture-bar> {(status == "idle" || status == "starting") && <Button size="lg" disabled={status == "starting"} on_click={open}>{label}</Button>} {status == "live" && <Button size="lg" variant="outline" tone="neutral" on_click={() => { release(); status = "idle" }}>Close the camera</Button>} {status == "review" && <Button size="lg" on_click={accept}>Use this clip</Button>} {(status == "review" || status == "accepted") && <Button size="lg" variant="outline" tone="neutral" on_click={again}>Retake</Button>} {fallback && <Button size="lg" variant="outline" tone="neutral" on_click={open}>Try again</Button>} {status == "refused" && settings && <Button size="lg" variant="outline" tone="neutral" on_click={() => { js("quilloNative.openSettings") }}>Open Settings</Button>} <label class={ui_cls(["ui-video-capture-file", fallback && "ui-video-capture-file-on"])}> <span>Use the phone's camera</span> <input type="file" id={id} name={name} accept="video/*" capture={facing} @mount={(el) => { input = el }} @change={take_file}/> </label> </div> {(status == "review" || status == "accepted") && clip != null && <p.ui-video-capture-note> {clip.seconds != null ? `${round(clip.seconds, 1)} s, ` : ""}{round(clip.size / 1024)} KB{silent ? ", no sound" : ""} {status == "accepted" ? ". Ready to send." : ". Not sent until you use it."} </p>} {hint != null && <p.ui-video-capture-hint>{hint}</p>} {said != "" && <p.ui-video-capture-why role="alert">{said}</p>} </div> </div> </div>}Related
PhotoCaptureTakes a photo through the phone's camera, with preview and retake, made smaller on the phone and upright by its EXIF, as a real file in a form.3 views · 5 downloadsDocumentCapturePhotographs pages one by one, with reorder, drop, and a crop by four corners by finger that flattens the page, kept as a list of images.4 views · 7 downloadsGalleryPickerPicks several photos through the system's picker, with thumbnails, an order, caps by count and size, HEIC refused with a reason, and previews freed after.2 views · 5 downloadsStoryCaptureA stories camera: hold to record, tap for a photo, with a progress ring, a switch of camera, and a screen for a refused permission.2 views · 6 downloadsVoiceRecorderRecords from the microphone with a live level, pause, playback and a limit, says so when refused, and leaves a real file in a form.3 views · 5 downloadsDateWheelsMonth, day and year wheels that keep each other valid, with limits, and a value in ISO.2 views · 6 downloads
Thread
Thread 0 posts
On VideoCapture: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.