diff options
| author | Adam Soltys <[email protected]> | 2026-09-30 10:10:10 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-30 10:10:10 -0700 |
| commit | 505cd84813a80e0a69b2cc95aa93c68cf2856cb2 (patch) | |
| tree | 3453513920b23476e01efe508e73ba8651f438da | |
| parent | 93c07bec934e8e8d847349b7792f4cc9089adbb7 (diff) | |
| download | coinosv3-505cd84813a80e0a69b2cc95aa93c68cf2856cb2.tar.gz coinosv3-505cd84813a80e0a69b2cc95aa93c68cf2856cb2.zip | |
Videos are shrunk on the phone before upload; nostr.build is the first media host.
No public Blossom host transcodes (nostr.build's and primal's BUD-05 /media
return the same bitrate), so a 15-20 Mbps phone clip stalled on playback.
src/video-compress.js re-encodes on the device with WebCodecs via
Mediabunny (a lazily loaded chunk, left out of the SW precache and the
standalone file): H.264, short edge 720, ~2.5 Mbps, moov first, audio
passed through, rotation baked in. Anything short of a clear win uploads
the original. A toast counts the shrinking down.
Media hosts: blossom.nostr.build first, nostr.download second (it was
unreachable for hours); a HEAD probe skips a host that doesn't answer
within 5 s instead of waiting out the whole upload timeout.
video-compress-test: 1080p 20 Mbps 5 s (landscape and rotated portrait) →
1.6 MB H.264 720p 2.49 Mbps with audio, moov first, in 2-3 s.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp
| -rw-r--r-- | build.js | 13 | ||||
| -rw-r--r-- | bun.lock | 7 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | src/app.js | 11 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/media-upload.js | 17 | ||||
| -rw-r--r-- | src/video-compress.js | 54 | ||||
| -rw-r--r-- | tools/media-upload-test.js | 18 | ||||
| -rw-r--r-- | tools/video-compress-test.js | 101 |
9 files changed, 217 insertions, 7 deletions
@@ -332,7 +332,13 @@ async function bundleApp({ minify, features, staging, splitting = false }) { // The split build emits real ESM chunks (deferred features arrive as their // own files); the single-file build inlines the dynamic imports instead. ...(splitting ? { splitting: true, format: 'esm', naming: { chunk: 'chunk-[hash].[ext]' } } : {}), - plugins: [featurePlugin(enabledFeatures(features)), flagsPlugin(staging)], + plugins: [featurePlugin(enabledFeatures(features)), flagsPlugin(staging), + // the single file inlines every dynamic import: the video compressor + // (Mediabunny, ~700 KB) would ride along in a file meant to be small + // and offline — there it uploads the original, as before + ...(splitting ? [] : [{ name: 'no-video-compress', setup(build) { + build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' })); + } }])], }); if (!result.success) { for (const log of result.logs) console.error(log); @@ -470,7 +476,10 @@ if (import.meta.main) { const nwcSw = feats.includes('nwc') && feats.includes('ark'); const swBody = SW .replaceAll('{{VERSION}}', version) - .replaceAll('{{EXTRA_SHELL}}', chunks.map((c) => `, '${c.name}'`).join('')) + // precached for offline use — except what only works online anyway: + // the video compressor (Mediabunny, ~700 KB) loads when a video is + // picked for upload, not on every install + .replaceAll('{{EXTRA_SHELL}}', chunks.filter((c) => !c.text.includes('Mediabunny Codec Registry')).map((c) => `, '${c.name}'`).join('')) .replaceAll('{{NWC_WAKE_FALLBACK}}', nwcSw ? '' : SW_WAKE_ONLY); await Bun.write('dist/sw.js', nwcSw ? swBody + '\n' + await buildSwNwc() : swBody); for (const f of STATIC) await Bun.write('dist/' + f, Bun.file('static/' + f)); @@ -12,6 +12,7 @@ "@scure/bip39": "^1.6.0", "@scure/btc-signer": "^1.8.1", "jsqr": "^1.4.0", + "mediabunny": "^1.61.0", "nostr-tools": "^2.23.5", "qrcode-generator": "^2.0.4", }, @@ -43,6 +44,10 @@ "@scure/btc-signer": ["@scure/[email protected]", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5", "micro-packed": "~0.7.3" } }, "sha512-8nX9T++dFyKpvqksNHfSi9CgRsGnHAQtCdIQ1y1GmbCGLpV97v4MUyemUUT6uDumKL3oo3m4niyY6A32nmdLuQ=="], + "@types/dom-mediacapture-transform": ["@types/[email protected]", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw=="], + + "@types/dom-webcodecs": ["@types/[email protected]", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="], + "ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -67,6 +72,8 @@ "jsqr": ["[email protected]", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="], + "mediabunny": ["[email protected]", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-WciPOYwKZwgYkoBKcEK6JzzIf+w5whypPzFdOihOylZ/5Qv19FmNfRnTcaprH+jD136YpEASU30Eb6VNCJiz3A=="], + "micro-packed": ["[email protected]", "", { "dependencies": { "@scure/base": "~1.2.5" } }, "sha512-2Milxs+WNC00TRlem41oRswvw31146GiSaoCT7s3Xi2gMUglW5QBeqlQaZeHr5tJx9nm3i57LNXPqxOOaWtTYg=="], "mitt": ["[email protected]", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], diff --git a/package.json b/package.json index 6783bf9..1d12b14 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@scure/bip39": "^1.6.0", "@scure/btc-signer": "^1.8.1", "jsqr": "^1.4.0", + "mediabunny": "^1.61.0", "nostr-tools": "^2.23.5", "qrcode-generator": "^2.0.4" }, @@ -3483,6 +3483,17 @@ async function onbUpload(file) { } async function publicMediaUpload(file) { + // a phone video is shrunk on the device first (see video-compress.js); + // the toast counts it down, since a long clip takes a while + if (/^video\//.test(file?.type || '')) { + let shown = -1; + const { compressVideo } = await import('./video-compress.js'); + file = await compressVideo(file, (p) => { + const pct = Math.floor(p * 100); + if (pct !== shown && pct % 5 === 0) { shown = pct; toast(t('mediaShrinking', { pct }), 60_000); } + }); + if (shown >= 0) toast(t('mediaUploading'), 4000); + } try { return await uploadPublicMedia(file, (event) => wallet.nostrSign(event)); } catch (error) { diff --git a/src/i18n.js b/src/i18n.js index 41ccb36..f54e760 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -245,6 +245,8 @@ const STR = { feedDelete: 'Delete feed', feedSaveAdhoc: 'Save feed', feedSaved: 'Feed saved', + mediaShrinking: 'Shrinking the video for streaming… {pct}%', + mediaUploading: 'Uploading…', feedNoQuery: 'Add people or topics to this feed.', feedOfTitle: '{name}’s timeline', feedOfLoading: 'Reading who they follow…', diff --git a/src/media-upload.js b/src/media-upload.js index 686fec5..8e5e8ed 100644 --- a/src/media-upload.js +++ b/src/media-upload.js @@ -2,10 +2,11 @@ import { sha256 } from '@noble/hashes/sha256'; import { bytesToHex } from '@noble/hashes/utils'; // Independent public hosts: a phone upload should not fail just because one -// provider is down or rejects a particular container. nostr.download's NIP-96 -// document currently advertises large image/video/audio uploads; nostr.build -// is the second, widely-used Blossom home. -export const PUBLIC_MEDIA_SERVERS = ['https://nostr.download', 'https://blossom.nostr.build']; +// provider is down or rejects a particular container. nostr.build is the +// widely-used Blossom home and goes first; nostr.download (which advertises +// large image/video/audio uploads) is the fallback — it was unreachable for +// hours on 2026-09-30, and every upload waited on it. +export const PUBLIC_MEDIA_SERVERS = ['https://blossom.nostr.build', 'https://nostr.download']; export class MediaUploadError extends Error { constructor(code, details = '') { @@ -59,7 +60,13 @@ export async function uploadPublicMedia(file, signEvent, { if (!auth) throw new MediaUploadError('failed'); const authorization = 'Nostr ' + btoa(JSON.stringify(auth)); const failures = []; - for (const server of servers) { + for (const [i, server] of servers.entries()) { + // A host that doesn't answer at all costs a few seconds, not the whole + // upload timeout. Any HTTP answer, even an error, means it's there. + if (i < servers.length - 1) { + try { await fetcher(server.replace(/\/$/, '') + '/upload', { method: 'HEAD', signal: AbortSignal.timeout(5000) }); } + catch (error) { failures.push({ status: 0, reason: 'unreachable' }); continue; } + } try { const response = await fetcher(server.replace(/\/$/, '') + '/upload', { method: 'PUT', body: file, signal: AbortSignal.timeout(timeoutMs), diff --git a/src/video-compress.js b/src/video-compress.js new file mode 100644 index 0000000..53d4eb0 --- /dev/null +++ b/src/video-compress.js @@ -0,0 +1,54 @@ +// A phone's video, made light enough to stream before it's uploaded. +// +// A phone records 1080p–4K at 15–20 Mbps; played back from a Blossom host +// over mobile data that stalls every few seconds, and none of the public +// hosts transcode (nostr.build's and primal's BUD-05 /media endpoints hand +// back the same bitrate — measured 2026-09-30). So the phone does it: +// WebCodecs on the device's hardware encoder, via Mediabunny (loaded only +// when a video is picked), to H.264 at 720p and ~2.5 Mbps, the moov atom up +// front so playback starts before the download ends. Audio passes through +// untouched when the container allows (phones record AAC already). +// +// Anything short of a clear win — no WebCodecs, an unreadable file, an +// encoder error, a result not meaningfully smaller — uploads the original, +// exactly as before. + +const SHORT_EDGE = 720; +const VIDEO_BPS = 2_500_000; + +export async function compressVideo(file, onProgress = () => {}) { + if (!file || !/^video\//.test(file.type || '')) return file; + if (typeof VideoEncoder === 'undefined' || typeof VideoDecoder === 'undefined') return file; + let mb; + try { mb = await import('mediabunny'); } catch { return file; } + const { Input, Output, Conversion, BlobSource, BufferTarget, Mp4OutputFormat, ALL_FORMATS } = mb; + const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS }); + try { + const video = await input.getPrimaryVideoTrack(); + if (!video) return file; + const seconds = await input.computeDuration(); + const w = video.displayWidth, h = video.displayHeight; + const short = Math.min(w, h); + const bps = seconds > 0 ? (file.size * 8) / seconds : Infinity; + // already light: nothing to gain, and a re-encode only loses quality + if (short <= SHORT_EDGE && bps <= VIDEO_BPS * 1.4) return file; + const size = short > SHORT_EDGE ? (w >= h ? { height: SHORT_EDGE } : { width: SHORT_EDGE }) : {}; + const output = new Output({ format: new Mp4OutputFormat({ fastStart: 'in-memory' }), target: new BufferTarget() }); + const conversion = await Conversion.init({ + input, output, + video: { ...size, codec: 'avc', bitrate: VIDEO_BPS, forceTranscode: true }, + }); + if (!conversion.isValid) return file; + conversion.onProgress = (p) => { try { onProgress(p); } catch {} }; + await conversion.execute(); + const buf = output.target.buffer; + // not worth it unless it's clearly smaller + if (!buf || buf.byteLength > file.size * 0.8) return file; + const name = String(file.name || 'video').replace(/\.[a-z0-9]{1,8}$/i, '') + '.mp4'; + return new File([buf], name, { type: 'video/mp4' }); + } catch { + return file; + } finally { + try { input.dispose?.(); } catch {} + } +} diff --git a/tools/media-upload-test.js b/tools/media-upload-test.js index f9cc56a..e19cac7 100644 --- a/tools/media-upload-test.js +++ b/tools/media-upload-test.js @@ -10,6 +10,7 @@ let signed = null; const sign = async (event) => (signed = { ...event, id: '1'.repeat(64), pubkey: '2'.repeat(64), sig: '3'.repeat(128) }); const calls = []; const fetcher = async (url, init) => { + if (init?.method === 'HEAD') return new Response('', { status: 200 }); // the reachability probe calls.push({ url, init }); if (url.startsWith('https://first')) return new Response(JSON.stringify({ message: 'temporarily unavailable' }), { status: 503 }); return new Response(JSON.stringify({ url: 'https://cdn.example/clip.mov' }), { status: 201, headers: { 'content-type': 'application/json' } }); @@ -23,6 +24,23 @@ check('hashes the file and binds the hash into Blossom auth', calls[1].init.head && signed.tags.some((tag) => tag[0] === 'x' && tag[1] === calls[1].init.headers['x-sha-256'])); check('sends signed Blossom authorization', calls[1].init.headers.authorization.startsWith('Nostr ')); +// a host that never answers is skipped after the probe, not waited on +{ + const puts = []; + const t0 = Date.now(); + const u = await uploadPublicMedia(file, sign, { + servers: ['https://dead.example', 'https://alive.example'], timeoutMs: 60_000, + fetcher: async (url, init) => { + if (url.startsWith('https://dead')) return new Promise((_, reject) => init.signal.addEventListener('abort', () => reject(new Error('timeout')))); + if (init.method === 'HEAD') return new Response('', { status: 404 }); + puts.push(url); + return new Response(JSON.stringify({ url: 'https://alive.example/x.mov' }), { status: 201 }); + }, + }); + check('an unreachable host is skipped within seconds', u === 'https://alive.example/x.mov' && Date.now() - t0 < 7000 && puts.length === 1); +} +check('nostr.build is the first host', (await import('../src/media-upload.js')).PUBLIC_MEDIA_SERVERS[0] === 'https://blossom.nostr.build'); + const bareHashUrl = await uploadPublicMedia(file, sign, { servers: ['https://media.example'], timeoutMs: 1000, fetcher: async () => new Response(JSON.stringify({ url: '/' + 'a'.repeat(64) }), { status: 201 }), diff --git a/tools/video-compress-test.js b/tools/video-compress-test.js new file mode 100644 index 0000000..dc17036 --- /dev/null +++ b/tools/video-compress-test.js @@ -0,0 +1,101 @@ +// A phone video is shrunk on the device before upload: 1080p at 20 Mbps in, +// H.264 720p around 2.5 Mbps out, moov first (streams while downloading), +// audio kept. Pasted into the reply box like a keyboard/gallery pick; the +// upload is caught and its bytes checked with ffprobe. +// +// Run: bun tools/video-compress-test.js +import puppeteer from 'puppeteer-core'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const SRC = '/tmp/coinos-video-compress-src.mp4', OUT = '/tmp/coinos-video-compress-out.mp4'; +// --portrait: the way phones record upright video — landscape frames plus a +// 90° rotation flag in the container +const PORTRAIT = process.argv.includes('--portrait'); +Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=1920x1080:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440', + '-t', '5', '-c:v', 'libx264', '-b:v', '20M', '-maxrate', '20M', '-bufsize', '20M', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-shortest', SRC]); +if (PORTRAIT) { + // (ffmpeg 4 writes the flag only on a remux) + Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-i', SRC, '-c', 'copy', '-metadata:s:v:0', 'rotate=90', SRC + '.rot.mp4']); + Bun.spawnSync(['mv', SRC + '.rot.mp4', SRC]); + const r = JSON.parse(new TextDecoder().decode(Bun.spawnSync(['ffprobe', '-v', 'error', '-show_streams', '-of', 'json', SRC]).stdout)).streams[0]; + console.log(' source rotation:', JSON.stringify(r.side_data_list || r.tags?.rotate || 'none')); +} +const srcSize = Bun.file(SRC).size; +// the split build: the compressor and Mediabunny are chunks loaded on demand +const built = Bun.spawnSync(['bun', 'run', 'build.js'], { stdout: 'pipe', stderr: 'pipe' }); +if (built.exitCode) { console.log(new TextDecoder().decode(built.stderr)); process.exit(1); } +const dist = 'dist'; +const server = Bun.serve({ port: 5287, fetch: async (req) => { + const p = new URL(req.url).pathname; + if (p === '/src.mp4') return new Response(Bun.file(SRC), { headers: { 'content-type': 'video/mp4' } }); + const f = Bun.file(dist + (p === '/' || !p.includes('.') ? '/index.html' : p)); + return (await f.exists()) ? new Response(f) : new Response(Bun.file(dist + '/index.html')); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument(() => { + const f = window.fetch; + window.fetch = async (url, init) => { + if (init && init.method === 'PUT' && /nostr\.build|nostr\.download/.test(String(url))) { + window.__put = { size: init.body.size, type: init.body.type, buf: await init.body.arrayBuffer() }; + return new Response(JSON.stringify({ url: 'https://blossom.nostr.build/' + 'f'.repeat(64) + '.mp4' }), { status: 201 }); + } + if (init && init.method === 'HEAD' && /nostr\.build|nostr\.download/.test(String(url))) return new Response('', { status: 200 }); + return f(url, init); + }; +}); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, ms = 15000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5287/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(600); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + await click('skip verification'); + await waitText('receive', 20000); + check('the browser can encode H.264 (WebCodecs)', await page.evaluate(async () => (await VideoEncoder.isConfigSupported({ codec: 'avc1.42001f', width: 1280, height: 720, bitrate: 2.5e6 })).supported)); + // the post composer: the pencil on the feed + await page.evaluate(() => document.querySelector('.app-bottom-nav .app-nav-button').click()); + await sleep(1500); + await page.evaluate(() => document.querySelector('button[aria-label="New post"], button[title="New post"]')?.click()); + const box = await waitFor(() => !!document.querySelector('.post-input'), 8000); + check('the post composer opens', box); + const t0 = Date.now(); + await page.evaluate(async () => { + const b = await (await fetch('/src.mp4')).blob(); + const dt = new DataTransfer(); + dt.items.add(new File([b], 'phone.mp4', { type: 'video/mp4' })); + document.querySelector('.post-input').dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); + }); + const sawShrink = await waitText('shrinking the video', 15000); + check('the toast says it is shrinking the video', sawShrink); + const done = await waitFor(() => !!window.__put, 120000); + const put = done && await page.evaluate(() => ({ size: window.__put.size, type: window.__put.type })); + const secs = ((Date.now() - t0) / 1000).toFixed(1); + check('the upload is the shrunk video', !!put && put.type === 'video/mp4' && put.size < srcSize * 0.35, + put ? `${(srcSize / 1e6).toFixed(1)} MB → ${(put.size / 1e6).toFixed(1)} MB in ${secs}s` : 'no upload'); + if (put) { + const b64 = await page.evaluate(() => { let s = ''; const u = new Uint8Array(window.__put.buf); for (let i = 0; i < u.length; i += 0x8000) s += String.fromCharCode(...u.subarray(i, i + 0x8000)); return btoa(s); }); + await Bun.write(OUT, Buffer.from(b64, 'base64')); + const probe = JSON.parse(new TextDecoder().decode(Bun.spawnSync(['ffprobe', '-v', 'error', '-show_streams', '-show_format', '-of', 'json', OUT]).stdout)); + const v = probe.streams.find((s) => s.codec_type === 'video'), a = probe.streams.find((s) => s.codec_type === 'audio'); + check('...H.264 at 720p', v?.codec_name === 'h264' && Math.min(v.width, v.height) === 720, `${v?.codec_name} ${v?.width}x${v?.height}`); + if (PORTRAIT) { + // upright on screen: either the frames are portrait, or they keep the rotation flag + const rot = Math.abs(+((v.side_data_list || []).find((d) => d.rotation != null)?.rotation ?? v.tags?.rotate ?? 0)); + check('...still upright (portrait)', (v.height > v.width) !== (rot === 90 || rot === 270), `${v.width}x${v.height} rotation ${rot}`); + } + check('...around 2.5 Mbps', v && +v.bit_rate < 3.5e6, (+v?.bit_rate / 1e6).toFixed(2) + ' Mbps'); + check('...the whole clip, with its sound', Math.abs(+probe.format.duration - 5) < 0.3 && !!a, probe.format.duration + 's, audio ' + a?.codec_name); + const head = new Uint8Array(await Bun.file(OUT).slice(0, 4096).arrayBuffer()); + const txt = new TextDecoder('latin1').decode(head); + check('...moov before mdat, so it plays while downloading', txt.indexOf('moov') >= 0 && (txt.indexOf('mdat') < 0 || txt.indexOf('moov') < txt.indexOf('mdat'))); + } +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ videos are shrunk before upload' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
