mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(mobile): rooms, live and upload capture, and the altitude-band identity
The Now feed mixes sensor, eye, track, number, witness and room cards across Orbit, Sky, Ground and Inside, ordered so neighbours differ, with composited thumbnails and live public-camera frames. Every situation has a room: typed, place- and time-anchored posts, evidence badges, bridging-rated notes and a cited Known / Disputed / Unknown local synthesis, with cross-tab presence over BroadcastChannel. Example posts are labelled as such. Capture adds Live (10 s segments sealed into a hash chain, shown on the globe as a view frustum) and Upload (EXIF placement, Unverified tier), and an attach sheet that files any capture into a nearby situation. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
38 files changed
+9837
-1081
No files matched your search
+31
-3
@@ -1,7 +1,10 @@
|
||||
/** Minimal promise wrapper over the device's IndexedDB for keys and captures. */
|
||||
/**
|
||||
* Minimal promise wrapper over the device's IndexedDB: signing keys, sealed
|
||||
* captures (+ media), and round two's room posts, uploads and small state.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'gev-mobile';
|
||||
const DB_VERSION = 1;
|
||||
const DB_VERSION = 2;
|
||||
let dbp = null;
|
||||
|
||||
export function openDb() {
|
||||
@@ -22,9 +25,27 @@ export function openDb() {
|
||||
}
|
||||
if (!db.objectStoreNames.contains('media'))
|
||||
db.createObjectStore('media', { keyPath: 'id' });
|
||||
// v2: rooms and uploads.
|
||||
if (!db.objectStoreNames.contains('posts')) {
|
||||
const p = db.createObjectStore('posts', { keyPath: 'id' });
|
||||
p.createIndex('room', 'room');
|
||||
}
|
||||
if (!db.objectStoreNames.contains('uploads'))
|
||||
db.createObjectStore('uploads', { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains('kv'))
|
||||
db.createObjectStore('kv', { keyPath: 'id' });
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
// Another tab upgraded the schema: let it, and reopen next time.
|
||||
db.onversionchange = () => {
|
||||
db.close();
|
||||
dbp = null;
|
||||
};
|
||||
resolve(db);
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onblocked = () => reject(new Error('IndexedDB upgrade blocked'));
|
||||
});
|
||||
dbp.catch(() => (dbp = null));
|
||||
return dbp;
|
||||
@@ -91,3 +112,10 @@ export async function captureBySeq(seq) {
|
||||
db.transaction('captures').objectStore('captures').index('seq').get(seq),
|
||||
);
|
||||
}
|
||||
|
||||
export async function byIndex(store, index, key) {
|
||||
const db = await openDb();
|
||||
return wrap(
|
||||
db.transaction(store).objectStore(store).index(index).getAll(key),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Tiny, dependency-free EXIF reader for JPEG: GPS position, altitude and
|
||||
* image direction, DateTimeOriginal (+ offset), orientation, make/model.
|
||||
* Returns null for anything that is not a JPEG with an Exif APP1 segment.
|
||||
*/
|
||||
|
||||
const TYPE_SIZE = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 };
|
||||
|
||||
function readIfd(view, tiff, offset, le) {
|
||||
const out = {};
|
||||
if (offset + 2 > view.byteLength) return out;
|
||||
const n = view.getUint16(offset, le);
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const e = offset + 2 + i * 12;
|
||||
if (e + 12 > view.byteLength) break;
|
||||
const tag = view.getUint16(e, le);
|
||||
const type = view.getUint16(e + 2, le);
|
||||
const count = view.getUint32(e + 4, le);
|
||||
const size = (TYPE_SIZE[type] || 1) * count;
|
||||
const at = size > 4 ? tiff + view.getUint32(e + 8, le) : e + 8;
|
||||
if (at + size > view.byteLength) continue;
|
||||
out[tag] = readValue(view, at, type, count, le);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readValue(view, at, type, count, le) {
|
||||
const one = (k) => {
|
||||
const p = at + k * (TYPE_SIZE[type] || 1);
|
||||
switch (type) {
|
||||
case 1:
|
||||
case 7:
|
||||
return view.getUint8(p);
|
||||
case 3:
|
||||
return view.getUint16(p, le);
|
||||
case 4:
|
||||
return view.getUint32(p, le);
|
||||
case 9:
|
||||
return view.getInt32(p, le);
|
||||
case 5: {
|
||||
const d = view.getUint32(p + 4, le);
|
||||
return d ? view.getUint32(p, le) / d : 0;
|
||||
}
|
||||
case 10: {
|
||||
const d = view.getInt32(p + 4, le);
|
||||
return d ? view.getInt32(p, le) / d : 0;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
if (type === 2) {
|
||||
let s = '';
|
||||
for (let k = 0; k < count; k += 1) {
|
||||
const c = view.getUint8(at + k);
|
||||
if (!c) break;
|
||||
s += String.fromCharCode(c);
|
||||
}
|
||||
return s.trim();
|
||||
}
|
||||
if (count === 1) return one(0);
|
||||
return Array.from({ length: count }, (_, k) => one(k));
|
||||
}
|
||||
|
||||
const dms = (v) =>
|
||||
Array.isArray(v) ? v[0] + (v[1] || 0) / 60 + (v[2] || 0) / 3600 : Number(v);
|
||||
|
||||
/** "2026:10:18 20:53:37" (+ "-05:00") → ms epoch; local time if no offset. */
|
||||
function exifDate(s, offset) {
|
||||
const m = /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(s || '');
|
||||
if (!m) return null;
|
||||
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${offset && /^[+-]\d{2}:\d{2}$/.test(offset) ? offset : ''}`;
|
||||
const t = Date.parse(iso);
|
||||
return Number.isFinite(t) ? t : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Blob|ArrayBuffer} input
|
||||
* @returns {Promise<null | { lat?, lon?, altM?, headingDeg?, takenAt?, takenAtHasZone?, orientation?, make?, model?, raw }>}
|
||||
*/
|
||||
export async function readExif(input) {
|
||||
const buf =
|
||||
input instanceof Blob
|
||||
? await input.slice(0, 256 * 1024).arrayBuffer()
|
||||
: input;
|
||||
const view = new DataView(buf);
|
||||
if (view.byteLength < 4 || view.getUint16(0) !== 0xffd8) return null;
|
||||
let p = 2;
|
||||
while (p + 4 <= view.byteLength) {
|
||||
if (view.getUint8(p) !== 0xff) return null;
|
||||
const marker = view.getUint8(p + 1);
|
||||
const len = view.getUint16(p + 2);
|
||||
if (marker === 0xda || marker === 0xd9) return null; // start of scan: no EXIF before it
|
||||
if (
|
||||
marker === 0xe1 &&
|
||||
p + 10 <= view.byteLength &&
|
||||
view.getUint32(p + 4) === 0x45786966
|
||||
) {
|
||||
const tiff = p + 10;
|
||||
const le = view.getUint16(tiff) === 0x4949;
|
||||
if (view.getUint16(tiff + 2, le) !== 42) return null;
|
||||
const ifd0 = readIfd(view, tiff, tiff + view.getUint32(tiff + 4, le), le);
|
||||
const exif = ifd0[0x8769]
|
||||
? readIfd(view, tiff, tiff + ifd0[0x8769], le)
|
||||
: {};
|
||||
const gps = ifd0[0x8825]
|
||||
? readIfd(view, tiff, tiff + ifd0[0x8825], le)
|
||||
: {};
|
||||
const out = { raw: { ifd0, exif, gps } };
|
||||
if (gps[2] != null && gps[4] != null) {
|
||||
let lat = dms(gps[2]);
|
||||
let lon = dms(gps[4]);
|
||||
if (String(gps[1]).toUpperCase() === 'S') lat = -lat;
|
||||
if (String(gps[3]).toUpperCase() === 'W') lon = -lon;
|
||||
if (
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lon) &&
|
||||
Math.abs(lat) <= 90 &&
|
||||
Math.abs(lon) <= 180 &&
|
||||
(lat || lon)
|
||||
) {
|
||||
out.lat = lat;
|
||||
out.lon = lon;
|
||||
}
|
||||
}
|
||||
if (gps[6] != null) out.altM = (gps[5] === 1 ? -1 : 1) * Number(gps[6]);
|
||||
if (gps[0x11] != null) out.headingDeg = Number(gps[0x11]);
|
||||
const offset = exif[0x9011] || exif[0x9010];
|
||||
const taken = exifDate(
|
||||
exif[0x9003] || exif[0x9004] || ifd0[0x0132],
|
||||
offset,
|
||||
);
|
||||
if (taken) {
|
||||
out.takenAt = taken;
|
||||
out.takenAtHasZone = !!offset;
|
||||
}
|
||||
if (ifd0[0x0112]) out.orientation = ifd0[0x0112];
|
||||
if (ifd0[0x010f]) out.make = ifd0[0x010f];
|
||||
if (ifd0[0x0110]) out.model = ifd0[0x0110];
|
||||
return out;
|
||||
}
|
||||
p += 2 + len;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Live broadcast session (DESIGN.md §11.4 "Live"): MediaRecorder timeslice
|
||||
* chunks are hashed and sealed every 10 s into a hash chain of segments,
|
||||
* each carrying the pose and position at that moment. Stopping returns the
|
||||
* whole recording, the chain and the sensor session, ready for `seal()`.
|
||||
*
|
||||
* Segment i covers bytes [offset, offset + bytes) of the final file, so a
|
||||
* verifier can re-split the file and recompute every link.
|
||||
*/
|
||||
|
||||
import { sha256Hex, canonical } from './provenance.js';
|
||||
|
||||
const TYPES = [
|
||||
'video/webm;codecs=vp9',
|
||||
'video/webm;codecs=vp8',
|
||||
'video/webm',
|
||||
'video/mp4;codecs=avc1',
|
||||
'video/mp4',
|
||||
];
|
||||
|
||||
export function canBroadcast(stream) {
|
||||
return !!stream && typeof MediaRecorder !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ stream: MediaStream, sensors: object, pose: () => object,
|
||||
* fix: () => object|null, segmentMs?: number, onSegment?: (seg) => void }} o
|
||||
*/
|
||||
export function createLiveSession(o) {
|
||||
const segmentMs = o.segmentMs || 10000;
|
||||
const mimeType = TYPES.find((t) => MediaRecorder.isTypeSupported?.(t));
|
||||
const chunks = [];
|
||||
let pending = [];
|
||||
const segments = [];
|
||||
let recorder = null;
|
||||
let timer = 0;
|
||||
let prev = null;
|
||||
let offset = 0;
|
||||
let t0 = 0;
|
||||
let segT0 = 0;
|
||||
let sealing = Promise.resolve();
|
||||
|
||||
async function sealSegment() {
|
||||
const parts = pending;
|
||||
pending = [];
|
||||
if (!parts.length) return null;
|
||||
const blob = new Blob(parts, { type: mimeType || 'video/webm' });
|
||||
const t1 = Date.now();
|
||||
const p = o.pose?.() || {};
|
||||
const f = o.fix?.() || null;
|
||||
const seg = {
|
||||
i: segments.length,
|
||||
t0: segT0,
|
||||
t1,
|
||||
offset,
|
||||
bytes: blob.size,
|
||||
media: await sha256Hex(blob),
|
||||
pose: {
|
||||
headingDeg: Number.isFinite(p.az) ? Math.round(p.az * 10) / 10 : null,
|
||||
pitchDeg: Number.isFinite(p.el) ? Math.round(p.el * 10) / 10 : null,
|
||||
},
|
||||
gps: f
|
||||
? {
|
||||
lat: +f.lat.toFixed(6),
|
||||
lon: +f.lon.toFixed(6),
|
||||
acc: Math.round(f.acc || 0),
|
||||
}
|
||||
: null,
|
||||
prev,
|
||||
};
|
||||
seg.hash = await sha256Hex(canonical(seg));
|
||||
prev = seg.hash;
|
||||
offset += blob.size;
|
||||
segT0 = t1;
|
||||
segments.push(seg);
|
||||
o.onSegment?.(seg);
|
||||
return seg;
|
||||
}
|
||||
|
||||
function start() {
|
||||
recorder = new MediaRecorder(
|
||||
o.stream,
|
||||
mimeType ? { mimeType, videoBitsPerSecond: 2.5e6 } : undefined,
|
||||
);
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (!e.data?.size) return;
|
||||
chunks.push(e.data);
|
||||
pending.push(e.data);
|
||||
};
|
||||
recorder.start(1000);
|
||||
t0 = Date.now();
|
||||
segT0 = t0;
|
||||
o.sensors?.beginSession?.();
|
||||
timer = setInterval(() => {
|
||||
sealing = sealing
|
||||
.then(sealSegment)
|
||||
.catch((err) => console.warn('segment', err));
|
||||
}, segmentMs);
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
clearInterval(timer);
|
||||
if (recorder && recorder.state !== 'inactive')
|
||||
await new Promise((resolve) => {
|
||||
recorder.onstop = resolve;
|
||||
recorder.stop();
|
||||
});
|
||||
await sealing;
|
||||
await sealSegment();
|
||||
const session = o.sensors?.endSession?.() || {
|
||||
start: new Date(t0).toISOString(),
|
||||
end: new Date().toISOString(),
|
||||
imu: [],
|
||||
gps: [],
|
||||
heading: null,
|
||||
};
|
||||
return {
|
||||
blob: new Blob(chunks, { type: mimeType || 'video/webm' }),
|
||||
segments,
|
||||
session,
|
||||
startedAt: t0,
|
||||
endedAt: Date.now(),
|
||||
mimeType: mimeType || 'video/webm',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
get segments() {
|
||||
return segments;
|
||||
},
|
||||
get startedAt() {
|
||||
return t0;
|
||||
},
|
||||
get active() {
|
||||
return !!recorder && recorder.state === 'recording';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Re-split a live file at the sealed offsets and recompute the chain. */
|
||||
export async function verifySegments(blob, segments) {
|
||||
let prev = null;
|
||||
for (const s of segments) {
|
||||
const part = blob.slice(s.offset, s.offset + s.bytes);
|
||||
const media = await sha256Hex(part);
|
||||
const { hash, ...core } = s;
|
||||
const h = await sha256Hex(canonical(core));
|
||||
if (media !== s.media || h !== hash || s.prev !== prev)
|
||||
return { ok: false, at: s.i };
|
||||
prev = hash;
|
||||
}
|
||||
return { ok: true, n: segments.length, head: prev };
|
||||
}
|
||||
Reference in new issue
Block a user