diff --git a/app/components/Collapsable.vue b/app/components/Collapsable.vue new file mode 100644 index 0000000..1fdb924 --- /dev/null +++ b/app/components/Collapsable.vue @@ -0,0 +1,31 @@ +<script lang="ts" setup> +import { watchEffect } from 'vue' + +const props = defineProps<{ + default?: boolean + label?: string +}>() +const isVisible = defineModel<boolean>({ default: false }) +watchEffect(() => { + if (props.default != null) { + isVisible.value = !!props.default + } +}) +</script> + +<template> + <div flex="~ col" border="~ gray/25 rounded-lg" divide="y dashed gray/25" max-w-150 of-hidden shadow-sm> + <button + flex items-center justify-between px2 py1 text-sm + @click="isVisible = !isVisible" + > + <span> + <slot name="label"> + {{ props.label ?? 'Inspect' }} + </slot></span> <span op50>{{ isVisible ? '▲' : '▼' }}</span> + </button> + <div v-if="isVisible"> + <slot /> + </div> + </div> +</template> diff --git a/app/components/Generate.vue b/app/components/Generate.vue index 6a4d539..9b476d2 100644 --- a/app/components/Generate.vue +++ b/app/components/Generate.vue @@ -1,50 +1,53 @@ <script lang="ts" setup> -import { encode, renderSVG } from 'uqr' +import type { EncodedBlock } from '~~/utils/lt-code' +import { blockToBinary, createEncoder } from '~~/utils/lt-code' +import { fromUint8Array } from 'js-base64' +import { renderSVG } from 'uqr' const props = withDefaults(defineProps<{ - data: string[] + data: Uint8Array speed: number }>(), { speed: 250, }) -const ecc = 'L' as const -const minVersion = computed(() => encode(props.data[0]! || '', { ecc }).version) -const svgList = computed(() => props.data.map(content => renderSVG(content, { - border: 1, - ecc, - minVersion: minVersion.value, -}))) -const activeIndex = ref(0) -watch(() => props.data, () => activeIndex.value = 0) +const count = ref(0) +const encoder = createEncoder(props.data, 1000) +const svg = ref<string>() +const block = shallowRef<EncodedBlock>() -let intervalId: any -function initInterval() { - intervalId = setInterval(() => { - activeIndex.value = (activeIndex.value + 1) % svgList.value.length - }, props.speed) -} -watch(() => props.speed, () => { - intervalId && clearInterval(intervalId) - initInterval() -}, { immediate: true }) -onUnmounted(() => intervalId && clearInterval(intervalId)) +const renderTime = ref(0) +const framePerSecond = computed(() => 1000 / renderTime.value) + +onMounted(() => { + let frame = performance.now() + + useIntervalFn(() => { + count.value++ + const data = encoder.fountain().next().value + block.value = data + const binary = blockToBinary(data) + const str = fromUint8Array(binary) + svg.value = renderSVG(str, { border: 1 }) + const now = performance.now() + renderTime.value = now - frame + frame = now + }, () => props.speed) +}) </script> <template> - <div flex flex-col items-center> - <p mb-4> - {{ activeIndex }}/{{ svgList.length }} + <div flex flex-col items-center pb-20> + <p mb-4 w-full of-x-auto ws-nowrap font-mono> + Indices: {{ block?.indices }}<br> + Total: {{ block?.k }}<br> + Bytes: {{ ((block?.bytes || 0) / 1024).toFixed(2) }} KB<br> + Bitrate: {{ ((block?.bytes || 0) / 1024 * framePerSecond).toFixed(2) }} Kbps<br> + Frame Count: {{ count }}<br> + FPS: {{ framePerSecond.toFixed(2) }} </p> <div class="relative h-full w-full"> <div - class="arc aspect-square" absolute inset-0 - :style="{ '--deg': `${(activeIndex + 1) * 360 / svgList.length}deg` }" - /> - <div - v-for="svg, idx of svgList" - :key="idx" - :class="{ hidden: idx !== activeIndex }" class="aspect-square [&>svg]:h-full [&>svg]:w-full" h-full w-full v-html="svg" diff --git a/app/components/Scan.vue b/app/components/Scan.vue index cf72e0e..67a81bc 100644 --- a/app/components/Scan.vue +++ b/app/components/Scan.vue @@ -1,6 +1,8 @@ <script lang="ts" setup> -import { merge, type SliceData } from '~~/utils/slicing' +import { binaryToBlock, createDecoder } from '~~/utils/lt-code' +import { toUint8Array } from 'js-base64' import { scan } from 'qr-scanner-wechat' +import { useBytesRate } from '~/composables/timeseries' const props = withDefaults(defineProps<{ speed?: number @@ -8,12 +10,33 @@ const props = withDefaults(defineProps<{ height?: number }>(), { speed: 34, - width: 512, - height: 512, + width: 1080, + height: 1080, }) -const { bytesReceived: validBytesReceivedInLastSecond, currentFormatted: currentValidBandwidthFormatted } = useBandwidth() -const { bytesReceived: bytesReceivedInLastSecond, currentFormatted: currentBandwidthFormatted } = useBandwidth() +enum CameraSignalStatus { + Waiting, + Ready, +} + +const bytesReceived = ref(0) +const totalValidBytesReceived = ref(0) + +const { formatted: currentValidBytesSpeedFormatted } = useBytesRate(totalValidBytesReceived, { + interval: 250, + timeWindow: 1000, + type: 'counter', + sampleRate: 50, + maxDataPoints: 100, +}) + +const { formatted: currentBytesFormatted } = useBytesRate(bytesReceived, { + interval: 250, + timeWindow: 1000, + type: 'counter', + sampleRate: 50, + maxDataPoints: 100, +}) const { devices } = useDevicesList({ requestPermissions: true, @@ -23,6 +46,7 @@ const { devices } = useDevicesList({ }, }) +const cameraSignalStatus = ref(CameraSignalStatus.Waiting) const cameras = computed(() => devices.value.filter(i => i.kind === 'videoinput')) const selectedCamera = ref(cameras.value[0]?.deviceId) @@ -31,7 +55,7 @@ watchEffect(() => { selectedCamera.value = cameras.value[0]?.deviceId }) -const results = defineModel<Set<string>>('results', { default: new Set() }) +// const results = defineModel<Set<string>>('results', { default: new Set() }) let stream: MediaStream | undefined @@ -54,7 +78,14 @@ onMounted(async () => { }, { immediate: true }) useIntervalFn( - () => scanFrame(), + async () => { + try { + await scanFrame() + } + catch (e) { + error.value = e + } + }, () => props.speed, ) }) @@ -83,13 +114,38 @@ async function connectCamera() { } } -const chunks: SliceData[] = reactive([]) +const decoder = ref(createDecoder()) +const k = ref(0) +const bytes = ref(0) +const checksum = ref(0) +const cached = new Set<string>() +const startTime = ref(0) +const endTime = ref(0) -const length = computed(() => chunks.find(i => i?.[1])?.[1] || 0) -const id = computed(() => chunks.find(i => i?.[0])?.[0] || 0) -const picked = computed(() => Array.from({ length: length.value }, (_, idx) => chunks[idx])) const dataUrl = ref<string>() const dots = useTemplateRef<HTMLDivElement[]>('dots') +const status = ref<number[]>([]) +const decodedBlocks = computed(() => status.value.filter(i => i === 1).length) +const receivedBytes = computed(() => decoder.value.encodedCount * (decoder.value.meta?.data.length ?? 0)) + +function getStatus() { + const array = Array.from({ length: k.value }, () => 0) + for (let i = 0; i < k.value; i++) { + if (decoder.value.decodedData[i] != null) + array[i] = 1 + } + for (const block of decoder.value.encodedBlocks) { + for (const i of block.indices) { + if (array[i] === 0 || array[i]! > block.indices.length) { + array[i] = block.indices.length + } + else { + console.warn(`Unexpected block #${i} status: ${array[i]}`) + } + } + } + return array +} function pluse(index: number) { const el = dots.value?.[index] @@ -97,7 +153,7 @@ function pluse(index: number) { return el.style.transition = 'none' el.style.transform = 'scale(1.3)' - el.style.filter = 'hue-rotate(90deg)' + el.style.filter = 'hue-rotate(-90deg)' // // force reflow void el.offsetWidth el.style.transition = 'transform 0.3s, filter 0.3s' @@ -110,53 +166,84 @@ async function scanFrame() { const canvas = document.createElement('canvas') canvas.width = video.value!.videoWidth canvas.height = video.value!.videoHeight + if (video.value!.videoWidth === 0 || video.value!.videoHeight === 0) { + cameraSignalStatus.value = CameraSignalStatus.Waiting + return + } + + cameraSignalStatus.value = CameraSignalStatus.Ready const ctx = canvas.getContext('2d')! ctx.drawImage(video.value!, 0, 0, canvas.width, canvas.height) - const result = await scan(canvas) - if (result?.text) { - setFps() - results.value.add(result.text) - const data = JSON.parse(result.text) as SliceData - if (Array.isArray(data)) { - if (data[0] !== id.value) { - chunks.length = 0 - dataUrl.value = undefined - } + const result = await scan(canvas) + if (!result.text) + return - // Bandwidth calculation - { - const chunkSize = data[4].length + setFps() + bytesReceived.value += result.text.length + totalValidBytesReceived.value = decoder.value.encodedCount * (decoder.value.meta?.data.length ?? 0) - if (!chunks[data[2]]) { - validBytesReceivedInLastSecond.value += chunkSize - } + // Do not process the same QR code twice + if (cached.has(result.text)) + return - bytesReceivedInLastSecond.value += chunkSize - } + error.value = undefined + const binary = toUint8Array(result.text) + const data = binaryToBlock(binary) + // Data set changed, reset decoder + if (checksum.value !== data.checksum) { + decoder.value = createDecoder() + checksum.value = data.checksum + bytes.value = data.bytes + k.value = data.k + startTime.value = performance.now() + endTime.value = 0 + cached.clear() + } + // The previous data set is already decoded, skip for any new blocks + else if (endTime.value) { + return + } - chunks[data[2]] = data - pluse(data[2]) - - if (!length.value) - return - if (picked.value.every(i => !!i)) { - try { - const merged = merge(picked.value as SliceData[]) - dataUrl.value = URL.createObjectURL(new Blob([merged], { type: 'application/octet-stream' })) - } - catch (e) { - error.value = e - } - } - } + cached.add(result.text) + k.value = data.k + data.indices.map(i => pluse(i)) + const success = decoder.value.addBlock(data) + status.value = getStatus() + if (success) { + endTime.value = performance.now() + const merged = decoder.value.getDecoded()! + dataUrl.value = URL.createObjectURL(new Blob([merged], { type: 'application/octet-stream' })) } + // console.log({ data }) + // if (Array.isArray(data)) { + // if (data[0] !== id.value) { + // chunks.length = 0 + // dataUrl.value = undefined + // } + + // + + // chunks[data[2]] = data + // pluse(data[2]) + + // if (!length.value) + // return + // if (picked.value.every(i => !!i)) { + // try { + // const merged = merge(picked.value as SliceData[]) + // dataUrl.value = URL.createObjectURL(new Blob([merged], { type: 'application/octet-stream' })) + // } + // catch (e) { + // error.value = e + // } + // } + // } } -watch(() => results.value.size, (size) => { - if (!size) - chunks.length = 0 -}) +function now() { + return performance.now() +} </script> <template> @@ -166,51 +253,102 @@ watch(() => results.value.size, (size) => { v-for="item of cameras" :key="item.deviceId" :class="{ 'text-blue': selectedCamera === item.deviceId, }" - class="border rounded-md px2 py1 text-sm shadow-sm" + px2 py1 text-sm shadow-sm + border="~ gray/25 rounded-lg" @click="selectedCamera = item.deviceId" > {{ item.label }} </button> </div> - <pre v-if="error" text-red v-text="error" /> - <div border="~ gray/25 rounded-lg" flex="~ col gap-2" mb--4 max-w-150 p2> - <div flex="~ gap-0.4 wrap"> - <div - v-for="x, idx in picked" - :key="idx" - ref="dots" - h-4 - w-4 - border="~ gray rounded" - :class="x ? 'bg-green border-green4' : 'bg-gray:50'" - /> + <pre v-if="error" overflow-x-auto text-red v-text="error" /> + + <Collapsable> + <p w-full of-x-auto ws-nowrap px2 py1 font-mono :class="endTime ? 'text-green' : ''"> + <span>Checksum: {{ checksum }}</span><br> + <span>Indices: {{ k }}</span><br> + <span>Decoded: {{ decodedBlocks }}</span><br> + <span>Received blocks: {{ decoder.encodedCount }}</span><br> + <span>Expected bytes: {{ (bytes / 1024).toFixed(2) }} KB</span><br> + <span>Received bytes: {{ (receivedBytes / 1024).toFixed(2) }} KB ({{ bytes === 0 ? 0 : (receivedBytes / bytes * 100).toFixed(2) }}%)</span><br> + <span>Timepassed: {{ (((endTime || now()) - startTime) / 1000).toFixed(2) }} s</span><br> + <span>Average bitrate: {{ (receivedBytes / 1024 / ((endTime || now()) - startTime) * 1000).toFixed(2) }} Kbps</span><br> + </p> + </Collapsable> + + <Collapsable v-if="k" label="Packets" :default="true"> + <div flex="~ col gap-2" max-w-150 p2> + <div flex="~ gap-0.4 wrap"> + <div + v-for="x, idx of status" + :key="idx" + ref="dots" + + flex="~ items-center justify-center" + h-4 w-4 overflow-hidden text-8px + border="~ rounded" + :class="x === 1 ? 'bg-green:100 border-green4!' : x > 1 ? 'bg-amber border-amber4 text-amber-900 dark:text-amber-200' : 'bg-gray:50 border-gray'" + :style="{ '--un-bg-opacity': Math.max(0.5, (11 - x) / 10) }" + > + {{ ([0, 1, 2].includes(x)) ? '' : x }} + </div> + </div> + </div> + </Collapsable> + + <Collapsable v-if="dataUrl" label="Download" :default="true"> + <div flex="~ col gap-2" max-w-150 p2> + <img :src="dataUrl"> + <a + class="w-max border border-gray:50 rounded-md px2 py1 text-sm hover:bg-gray:10" + :href="dataUrl" + download="foo.png" + >Download</a> </div> - <a - v-if="dataUrl" - class="w-max border border-gray:50 rounded-md px2 py1 text-sm hover:bg-gray:10" - :href="dataUrl" - download="foo.png" - >Download</a> + </Collapsable> + + <!-- This is a progress bar that is not accurate but feels comfortable. --> + <div v-if="k" relative h-2 max-w-150 rounded-lg bg-black:75 text-white font-mono shadow> + <div + absolute inset-y-0 h-full bg-green border="~ green4 rounded-lg" + :style="{ width: `${decodedBlocks === k ? 100 : (Math.min(1, receivedBytes / bytes * 0.66) * 100).toFixed(2)}%` }" + /> </div> - <div relative h-full max-h-150 max-w-150 w-full> + <div relative h-full max-h-150 max-w-150 w-full text="10px md:sm"> <video ref="video" autoplay muted playsinline :controls="false" aspect-square h-full w-full rounded-lg /> - <div absolute left-1 top-1 border border-gray:50 rounded-md bg-black:75 px2 py1 text-sm text-white font-mono shadow> - <template v-if="length"> - {{ picked.filter(p => !!p).length }} / {{ length }} + + <div absolute left-1 top-1 border="~ gray:50 rounded-md" bg-black:75 px2 py1 text-white font-mono shadow> + <template v-if="k"> + {{ (receivedBytes / 1024).toFixed(2) }} / {{ (bytes / 1024).toFixed(2) }} KB <span text-neutral-400>({{ (receivedBytes / bytes * 100).toFixed(2) }}%)</span> </template> <template v-else> No Data </template> </div> - <p absolute right-1 top-1 border border-gray:50 rounded-md bg-black:75 px2 py1 text-sm text-white font-mono shadow> - {{ shutterCount }} | {{ fps.toFixed(0) }} hz | {{ currentValidBandwidthFormatted }} <span text-neutral-400>({{ currentBandwidthFormatted }})</span> + <div + v-if="cameraSignalStatus === CameraSignalStatus.Waiting" + top="50%" left="[calc(50%-4.5ch)]" text="neutral-500" absolute flex flex-col items-center gap-2 font-mono + > + <div i-carbon:circle-dash animate-spin animate-duration-5000 text-3xl /> + <p>No Signal</p> + </div> + <p absolute right-1 top-1 border="~ gray:50 rounded-md" bg-black:75 px2 py1 text-white font-mono shadow> + {{ fps.toFixed(0) }} hz | {{ currentValidBytesSpeedFormatted }} <span text-neutral-400>({{ currentBytesFormatted }})</span> </p> </div> + + <div flex="~ gap-1 wrap" max-w-150 text-xs> + <div v-for="i, idx of decoder.encodedBlocks" :key="idx" border="~ gray/10 rounded" p1> + <template v-for="x, idy of i.indices" :key="x"> + <span v-if="idy !== 0" op25>, </span> + <span :style="{ color: `hsl(${x * 40}, 40%, 60%)` }">{{ x }}</span> + </template> + </div> + </div> </div> </template> diff --git a/app/composables/bandwidth.ts b/app/composables/bandwidth.ts deleted file mode 100644 index e25adc9..0000000 --- a/app/composables/bandwidth.ts +++ /dev/null @@ -1,54 +0,0 @@ -export function useBandwidth() { - const kiloBytesFormatter = new Intl.NumberFormat('en-US', { - style: 'unit', - unit: 'kilobyte-per-second', - unitDisplay: 'short', - }) - - const bytesReceived = ref(0) - const current = ref(0) - const animationFrameId = ref<number | null>(null) - const lastUpdateTime = ref(0) - - const currentFormatted = computed(() => { - return kiloBytesFormatter.format(current.value) - }) - - function updateBandwidth(timestamp: number) { - const now = timestamp - const elapsedTime = now - lastUpdateTime.value - - if (elapsedTime >= 1000) { - // Calculate bandwidth for the last second - current.value = Number.parseFloat( - ( - (bytesReceived.value / 1024) - / (elapsedTime / 1000) - ).toFixed(2), - ) - - // Reset for the next second - bytesReceived.value = 0 - - lastUpdateTime.value = now - } - - requestAnimationFrame(updateBandwidth) - } - - onMounted(() => { - animationFrameId.value = requestAnimationFrame(updateBandwidth) - }) - - onUnmounted(() => { - if (animationFrameId.value !== null) { - cancelAnimationFrame(animationFrameId.value) - } - }) - - return { - bytesReceived, - current, - currentFormatted, - } -} diff --git a/app/composables/timeseries.ts b/app/composables/timeseries.ts new file mode 100644 index 0000000..07c2ab2 --- /dev/null +++ b/app/composables/timeseries.ts @@ -0,0 +1,163 @@ +import type { ComputedRef, Ref } from 'vue' +import { computed, ref } from 'vue' + +interface TimeSeriesOptions { + type: 'counter' | 'gauge' + interval: number + maxDataPoints: number +} + +interface DataPoint { + time: number + value: number +} + +function timeSeriesOptions(options?: Partial<TimeSeriesOptions>): TimeSeriesOptions { + if (!options) { + return { + type: 'gauge', + interval: 250, + maxDataPoints: 100, + } + } + + return { + type: options?.type ?? 'gauge', + interval: options?.interval ?? 250, + maxDataPoints: options?.maxDataPoints ?? 100, + } +} + +export function useTimeSeries( + input: Ref<number> | ComputedRef<number>, + opts?: Partial<TimeSeriesOptions>, +) { + const options = timeSeriesOptions(opts) + + const timeSeries = ref<DataPoint[]>([]) + const lastValue = ref(0) + const lastSampleTime = ref(0) + + onAnimationFrame((now) => { + if (now - lastSampleTime.value < options.interval) { + return + } + + const currentValue = input.value + + switch (options.type) { + case 'counter': + // eslint-disable-next-line no-case-declarations + const diff = Math.max(0, currentValue - lastValue.value) + + timeSeries.value.push({ time: now, value: diff }) + lastValue.value = currentValue + break + case 'gauge': + timeSeries.value.push({ time: now, value: currentValue }) + break + } + + // GC + if (timeSeries.value.length > options.maxDataPoints) { + const excessPoints = timeSeries.value.length - options.maxDataPoints + timeSeries.value.splice(0, excessPoints) + } + + lastSampleTime.value = now + }) + + return timeSeries +} + +interface BytesRateOption { + sampleRate: number + timeWindow: number +} + +function bytesRateOptions(options?: Partial<BytesRateOption>): BytesRateOption { + if (!options) { + return { + sampleRate: 1000, + timeWindow: 10000, + } + } + + return { + sampleRate: options?.sampleRate ?? 1000, + timeWindow: options?.timeWindow ?? 10000, + } +} + +export function useBytesRate( + input: Ref<number> | ComputedRef<number>, + opts?: Partial<BytesRateOption & TimeSeriesOptions>, +) { + const options = bytesRateOptions(opts) + + const timeSeriesOpts = timeSeriesOptions(opts) + const timeSeries = useTimeSeries(input, timeSeriesOpts) + + const bytesRate = ref(0) + const lastUpdateTime = ref(0) + + onAnimationFrame((now) => { + if (now - lastUpdateTime.value < options.sampleRate) { + return + } + + const cutoffTime = now - options.timeWindow + const relevantPoints = timeSeries.value.filter(point => point.time > cutoffTime) + + if (relevantPoints.length < 2) { + bytesRate.value = 0 + } + + else { + const oldestPoint = relevantPoints[0]! + const newestPoint = relevantPoints[relevantPoints.length - 1]! + const timeDiff = newestPoint.time - oldestPoint.time + const valueDiff = relevantPoints.reduce((sum, point) => sum + point.value, 0) + + // bytes per second + bytesRate.value = timeDiff > 0 ? (valueDiff / timeDiff) * 1000 : 0 + } + + lastUpdateTime.value = now + }) + + const kiloBytesFormatter = new Intl.NumberFormat('en-US', { + style: 'unit', + unit: 'kilobyte-per-second', + unitDisplay: 'short', + }) + + const formatted = computed(() => { + const kiloBytesPerSecond = bytesRate.value / 1024 + return kiloBytesFormatter.format(Number.parseFloat(kiloBytesPerSecond.toFixed(2))) + }) + + return { + bytesRate, + formatted, + } +} + +export function onAnimationFrame(callback: (timestamp: number) => void) { + const animationFrameId = ref<number | null>(null) + + function onAnimationFrame(timestamp: number) { + callback(timestamp) + animationFrameId.value = requestAnimationFrame(onAnimationFrame) + } + + onMounted(() => { + animationFrameId.value = requestAnimationFrame(onAnimationFrame) + }) + + onUnmounted(() => { + if (animationFrameId.value !== null) { + cancelAnimationFrame(animationFrameId.value) + } + }) +} diff --git a/app/pages/index.vue b/app/pages/index.vue index ef33f30..a546b92 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,6 +1,4 @@ <script lang="ts" setup> -import { slice } from '~~/utils/slicing' - enum ReadPhase { Idle, Reading, @@ -8,31 +6,29 @@ enum ReadPhase { Ready, } -const count = ref(10) -const demoData = ref(false) -const speed = ref(50) +const error = ref<any>() +const speed = ref(100) const readPhase = ref<ReadPhase>(ReadPhase.Idle) -const data = ref<Array<any>>([]) - -if (demoData.value) { - data.value = Array.from({ length: count.value }, (_, i) => `hello world ${i}`) - readPhase.value = ReadPhase.Ready -} +const data = ref<Uint8Array | null>(null) async function onFileChange(file?: File) { if (!file) { readPhase.value = ReadPhase.Idle - data.value = [] - + data.value = null return } - readPhase.value = ReadPhase.Reading - const content = await file.arrayBuffer() - readPhase.value = ReadPhase.Chunking - const chunks = slice(content, 900) - readPhase.value = ReadPhase.Ready - data.value = chunks.map(i => JSON.stringify(i)) + try { + readPhase.value = ReadPhase.Reading + const buffer = await file.arrayBuffer() + data.value = new Uint8Array(buffer) + readPhase.value = ReadPhase.Ready + } + catch (e) { + error.value = e + readPhase.value = ReadPhase.Idle + data.value = null + } } </script> @@ -61,8 +57,13 @@ async function onFileChange(file?: File) { /> </div> </div> - <div v-if="readPhase === ReadPhase.Ready" h-full w-full flex justify-center> - <Generate :speed="speed" :data="data" min-h="[calc(100vh-250px)]" max-w="[calc(100vh-250px)]" h-full w-full /> + <div v-if="readPhase === ReadPhase.Ready && data" h-full w-full flex justify-center> + <Generate + :speed="speed" :data="data" + min-h="[calc(100vh-250px)]" + max-w="[calc(100vh-250px)]" + h-full w-full + /> </div> <InputFile v-else diff --git a/app/pages/scan.vue b/app/pages/scan.vue index c10f07e..eb8cba7 100644 --- a/app/pages/scan.vue +++ b/app/pages/scan.vue @@ -1,12 +1,10 @@ <script lang="ts" setup> const speed = ref(16) - -const results = ref(new Set<string>()) </script> <template> <div px-4 pt-4 space-y-10> - <Scan v-model:results="results" :speed="speed" /> + <Scan :speed="speed" /> <div max-w-150 w-full inline-flex flex-row items-center> <span min-w-40> <span pr-2 text-zinc-400>Speed</span> @@ -20,18 +18,18 @@ const results = ref(new Set<string>()) w-full flex-1 /> </div> - <h2 flex items-center gap-4 text-3xl> + <!-- <h2 flex items-center gap-4 text-3xl> Results: {{ results.size }} <button class="flex items-center gap2 border rounded-md px2 py1 text-base shadow" @click="results = new Set()"> <span i-carbon:clean inline-block /> Clear </button> - </h2> - <div> + </h2> --> + <!-- <div> <div v-for="(item, index) of results" :key="index" font-mono> ID:{{ index }}:<br> {{ item }} </div> - </div> + </div> --> </div> </template> diff --git a/test/lt-blocks.test.ts b/test/lt-blocks.test.ts new file mode 100644 index 0000000..aaef2c1 --- /dev/null +++ b/test/lt-blocks.test.ts @@ -0,0 +1,53 @@ +import { expect, it } from 'vitest' +import { createDecoder, createEncoder } from '../utils/lt-code' + +function randomBuffer(length: number) { + return new Uint8Array(length).map(() => Math.floor(Math.random() * 256)) +} + +function createEncoderWithIndices(count: number) { + const buffer = randomBuffer(count * 100 - 5) + const encoder = createEncoder(buffer, 100) + expect(encoder.k).toBe(count) + + return { + buffer, + encoder, + } +} + +it('cross-blocks resolving 1', () => { + const { buffer, encoder } = createEncoderWithIndices(3) + + const decoder = createDecoder() + decoder.addBlock(encoder.createBlock([0, 1, 2])) + decoder.addBlock(encoder.createBlock([1, 2])) + // Here we know [0] + decoder.addBlock(encoder.createBlock([0, 2])) + + const data = decoder.getDecoded() + expect(data).toBeDefined() + expect(data).toEqual(buffer) +}) + +// TODO: get it work +it.skip('cross-blocks resolving 2', () => { + const { buffer, encoder } = createEncoderWithIndices(5) + + const decoder = createDecoder() + decoder.addBlock(encoder.createBlock([0, 1, 2, 3, 4])) + decoder.addBlock(encoder.createBlock([0, 1])) + expect(decoder.decodedCount).toBe(0) + // Here we know [0, 1] and [2, 3, 4] + decoder.addBlock(encoder.createBlock([2, 3])) + // Here we can have [4] + expect(decoder.decodedCount).toBe(1) + decoder.addBlock(encoder.createBlock([1])) + // Here we can have [0] and [1] + expect(decoder.decodedCount).toBe(3) + decoder.addBlock(encoder.createBlock([4])) + + const data = decoder.getDecoded() + expect(data).toBeDefined() + expect(data).toEqual(buffer) +}) diff --git a/test/lt.test.ts b/test/lt.test.ts index ea97019..5236916 100644 --- a/test/lt.test.ts +++ b/test/lt.test.ts @@ -1,73 +1,81 @@ import fs from 'node:fs/promises' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { binaryToBlock, blockToBinary, createDecoder, encodeFountain } from '../utils/lt-codes' +import { fromUint8Array, toUint8Array } from 'js-base64' +import { expect, it } from 'vitest' +import { binaryToBlock, blockToBinary, createDecoder, createEncoder } from '../utils/lt-code' -describe('lt-codes', () => { - it('slice binary', async () => { - const input = (await fs.readFile(join('test', 'SampleJPGImage_100kbmb.jpg'), null)).buffer - const data = new Uint32Array(input) +const list: { + name: string + data: Uint8Array + only?: boolean + repeats?: number + size?: number +}[] = [ + { + name: 'generated-1', + data: new Uint8Array(1).fill(23), + // only: true, + // repeats: 0, + }, + { + name: 'generated-2', + data: new Uint8Array(1000).fill(1), + }, + { + name: 'generated-3', + data: new Uint8Array(1031).fill(1), + }, + { + name: 'sample-jpg', + data: new Uint8Array((await fs.readFile(join('test', 'SampleJPGImage_100kbmb.jpg'), null)).buffer), + size: 1200, + }, +] - const decoder = createDecoder() - let count = 0 - for (const block of encodeFountain(data, 1000)) { - count += 1 - if (count > 1000) - throw new Error('Too many blocks') - const binary = blockToBinary(block) - // Use the binary to transfer - const back = binaryToBlock(binary) - const result = decoder.addBlock([back]) - if (result) - break - } - - const result = decoder.getDecoded()! - expect(result).toBeDefined() - expect(result).toBeInstanceOf(Uint32Array) - expect(result.length).toBe(data.length) - }) - - it(`allow loss and disorder`, async () => { - const input = (await fs.readFile(join('test', 'SampleJPGImage_100kbmb.jpg'), null)).buffer - const data = new Uint32Array(input) +for (const item of list) { + let test = it + if (item.only) + test = (it as any).only + const { + data, + name, + repeats = 10, + size = 1000, + } = item + test(`slice binary: ${name} (size: ${size})`, { repeats }, async () => { + const encoder = createEncoder(data, size) const decoder = createDecoder() - let count = 0 - const packets: Uint32Array[] = [] - - // Encode the data - for (const block of encodeFountain(data, 1000)) { - count += 1 - if (count > 1000) - break - packets.push(blockToBinary(block)) - } - - // Dissupt the order of packets - packets.sort(() => Math.random() - 0.5) - // Simulate 50% of packet loss - packets.length = Math.floor(packets.length * 0.5) + let count = 0 - count = 0 - // Decode the data - for (const packet of packets) { + for (const block of encoder.fountain()) { count += 1 - if (count > 500) - throw new Error('Too many blocks') + const rate = count / encoder.k + if (rate > 10) { + throw new Error('Too many blocks, aborting') + } + const binary = blockToBinary(block) + const str = fromUint8Array(binary) + // Use the str to transfer - // Use the binary to transfer - const back = binaryToBlock(packet) - const result = decoder.addBlock([back]) + const b2 = toUint8Array(str) + const back = binaryToBlock(b2) + const result = decoder.addBlock(back) if (result) break } const result = decoder.getDecoded()! expect(result).toBeDefined() - expect(result).toBeInstanceOf(Uint32Array) + expect(result).toBeInstanceOf(Uint8Array) expect(result.length).toBe(data.length) + + expect( + +(count / encoder.k * 100).toFixed(2), + 'Data rate should be less than 200%', + ) + .toBeLessThan(250) // TODO: target 180% }) -}) +} diff --git a/utils/lt-code/checksum.ts b/utils/lt-code/checksum.ts new file mode 100644 index 0000000..70cfaa8 --- /dev/null +++ b/utils/lt-code/checksum.ts @@ -0,0 +1,30 @@ +function generateCRCTable() { + const crcTable = new Uint32Array(256) + for (let i = 0; i < 256; i++) { + let crc = i + for (let j = 8; j > 0; j--) { + if (crc & 1) { + crc = (crc >>> 1) ^ 0xEDB88320 // Polynomial used in CRC-32 + } + else { + crc = crc >>> 1 + } + } + crcTable[i] = crc >>> 0 + } + return crcTable +} +const crcTable = /* @__PURE__ */ generateCRCTable() +/** + * Get checksum of the data using CRC32 and XOR with k to ensure uniqueness for different chunking sizes + */ + +export function getChecksum(uint8Array: Uint8Array, k: number): number { + let crc = 0xFFFFFFFF // Initial value + for (let i = 0; i < uint8Array.length; i++) { + const byte = uint8Array[i]! + crc = (crc >>> 8) ^ crcTable[(crc ^ byte) & 0xFF]! + } + + return (crc ^ k ^ 0xFFFFFFFF) >>> 0 // Final XOR value and ensure 32-bit unsigned +} diff --git a/utils/lt-code/decoder.ts b/utils/lt-code/decoder.ts new file mode 100644 index 0000000..95c0cbf --- /dev/null +++ b/utils/lt-code/decoder.ts @@ -0,0 +1,123 @@ +import type { EncodedBlock } from './shared' +import { getChecksum } from './checksum' +import { xorUint8Array } from './shared' + +export function createDecoder(blocks?: EncodedBlock[]) { + return new LtDecoder(blocks) +} + +export class LtDecoder { + public decodedData: (Uint8Array | undefined)[] = [] + public decodedCount = 0 + public encodedCount = 0 + public encodedBlocks: Set<EncodedBlock> = new Set() + public meta: EncodedBlock = undefined! + + constructor(blocks?: EncodedBlock[]) { + if (blocks) { + for (const block of blocks) { + this.addBlock(block) + } + } + } + + // Add block and decode them on the fly + addBlock(block: EncodedBlock): boolean { + if (!this.meta) { + this.meta = block + this.decodedData = Array.from({ length: this.meta.k }) + } + + if (block.checksum !== this.meta.checksum) { + throw new Error('Adding block with different checksum') + } + this.encodedBlocks.add(block) + this.encodedCount += 1 + + this.propagateDecoded() + + return this.decodedCount === this.meta.k + } + + propagateDecoded() { + let changed = false + for (const block of this.encodedBlocks) { + let { data, indices } = block + + // We already have all the data from this block + if (indices.every(index => this.decodedData[index] != null)) { + this.encodedBlocks.delete(block) + continue + } + + // XOR the data + for (const index of indices) { + if (this.decodedData[index] != null) { + block.data = data = xorUint8Array(data, this.decodedData[index]!) + block.indices = indices = indices.filter(i => i !== index) + changed = true + } + } + + if (indices.length === 1 && this.decodedData[indices[0]!] == null) { + this.decodedData[indices[0]!] = block.data + this.decodedCount++ + this.encodedBlocks.delete(block) + changed = true + } + } + + for (const block of this.encodedBlocks) { + const { data, indices } = block + + // Use 1x2x3 XOR 2x3 to get 1 + if (indices.length >= 3) { + const lowerBlocks = Array.from(this.encodedBlocks).filter(i => i.indices.length === indices.length - 1) + for (const lower of lowerBlocks) { + const extraIndices = indices.filter(i => !lower.indices.includes(i)) + if (extraIndices.length === 1 && this.decodedData[extraIndices[0]!] == null) { + const extraData = xorUint8Array(data, lower.data) + const extraIndex = extraIndices[0]! + this.decodedData[extraIndex] = extraData + this.decodedCount++ + this.encodedBlocks.delete(lower) + changed = true + } + } + } + } + + // If making some progress, continue + if (changed) { + this.propagateDecoded() + } + } + + getDecoded(): Uint8Array | undefined { + if (this.decodedCount !== this.meta.k) { + return + } + if (this.decodedData.some(block => block == null)) { + return + } + const indiceSize = this.meta.data.length + const blocks = this.decodedData as Uint8Array[] + const decodedData = new Uint8Array(this.meta.bytes) + blocks.forEach((block, i) => { + const start = i * indiceSize + if (start + indiceSize > decodedData.length) { + for (let j = 0; j < decodedData.length - start; j++) { + decodedData[start + j] = block[j]! + } + } + else { + decodedData.set(block, i * indiceSize) + } + }) + const checksum = getChecksum(decodedData, this.meta.k) + if (checksum !== this.meta.checksum) { + throw new Error('Checksum mismatch') + } + return decodedData + } +} diff --git a/utils/lt-code/encoder.ts b/utils/lt-code/encoder.ts new file mode 100644 index 0000000..bd6ba56 --- /dev/null +++ b/utils/lt-code/encoder.ts @@ -0,0 +1,99 @@ +import type { EncodedBlock } from './shared' +import { getChecksum } from './checksum' + +export function createEncoder(data: Uint8Array, indiceSize: number) { + return new LtEncoder(data, indiceSize) +} + +export class LtEncoder { + public readonly k: number + public readonly indices: Uint8Array[] + public readonly checksum: number + public readonly bytes: number + + constructor( + public readonly data: Uint8Array, + public readonly indiceSize: number, + ) { + this.indices = sliceData(data, indiceSize) + this.k = this.indices.length + this.checksum = getChecksum(data, this.k) + this.bytes = data.length + } + + createBlock(indices: number[]): EncodedBlock { + const data = new Uint8Array(this.indiceSize) + for (const index of indices) { + const indice = this.indices[index]! + for (let i = 0; i < this.indiceSize; i++) { + data[i] = data[i]! ^ indice[i]! + } + } + + return { + k: this.k, + bytes: this.bytes, + checksum: this.checksum, + indices, + data, + } + } + + /** + * Generate random encoded blocks that **never** ends + */ + *fountain(): Generator<EncodedBlock> { + while (true) { + const degree = getRandomDegree(this.k) + const selectedIndices = getRandomIndices(this.k, degree) + yield this.createBlock(selectedIndices) + } + } +} + +function sliceData(data: Uint8Array, blockSize: number): Uint8Array[] { + const blocks: Uint8Array[] = [] + for (let i = 0; i < data.length; i += blockSize) { + const block = new Uint8Array(blockSize) + block.set(data.slice(i, i + blockSize)) + blocks.push(block) + } + return blocks +} + +// Use Ideal Soliton Distribution to select degree +function getRandomDegree(k: number): number { + const probabilities: number[] = Array.from({ length: k }, () => 0) + + // Calculate the probabilities of the Ideal Soliton Distribution + probabilities[0] = 1 / k // P(1) = 1/k + for (let d = 2; d <= k; d++) { + probabilities[d - 1] = 1 / (d * (d - 1)) + } + + // Accumulate the probabilities to generate the cumulative distribution + const cumulativeProbabilities: number[] = probabilities.reduce((acc, p, index) => { + acc.push(p + (acc[index - 1] || 0)) + return acc + }, [] as number[]) + + // Generate a random number between [0,1] and select the corresponding degree in the cumulative probabilities + const randomValue = Math.random() + for (let i = 0; i < cumulativeProbabilities.length; i++) { + if (randomValue < cumulativeProbabilities[i]!) { + return i + 1 + } + } + + return k // Theoretically, this line should never be reached +} + +// Randomly select indices of degree number of original data blocks +function getRandomIndices(k: number, degree: number): number[] { + const indices: Set<number> = new Set() + while (indices.size < degree) { + const randomIndex = Math.floor(Math.random() * k) + indices.add(randomIndex) + } + return Array.from(indices) +} diff --git a/utils/lt-code/index.ts b/utils/lt-code/index.ts new file mode 100644 index 0000000..c54e34b --- /dev/null +++ b/utils/lt-code/index.ts @@ -0,0 +1,3 @@ +export * from './decoder' +export * from './encoder' +export * from './shared' diff --git a/utils/lt-code/shared.ts b/utils/lt-code/shared.ts new file mode 100644 index 0000000..a33691d --- /dev/null +++ b/utils/lt-code/shared.ts @@ -0,0 +1,71 @@ +export interface EncodedHeader { + /** + * Number of original data blocks + */ + k: number + /** + * Data length for Uint8Array data + */ + bytes: number + /** + * Checksum, CRC32 and XOR of k + */ + checksum: number +} + +export interface EncodedBlock extends EncodedHeader { + indices: number[] + data: Uint8Array +} + +export function blockToBinary(block: EncodedBlock): Uint8Array { + const { k, bytes, checksum, indices, data } = block + const header = new Uint32Array([ + indices.length, + ...indices, + k, + bytes, + checksum, + ]) + const binary = new Uint8Array(header.length * 4 + data.length) + let offset = 0 + binary.set(new Uint8Array(header.buffer), offset) + offset += header.length * 4 + binary.set(data, offset) + return binary +} + +export function binaryToBlock(binary: Uint8Array): EncodedBlock { + const degree = new Uint32Array(binary.buffer, 0, 4)[0]! + const headerRest = Array.from(new Uint32Array(binary.buffer, 4, degree + 3)) + const indices = headerRest.slice(0, degree) + const [ + k, + bytes, + checksum, + ] = headerRest.slice(degree) as [number, number, number] + const data = binary.slice(4 * (degree + 4)) + return { + k, + bytes, + checksum, + indices, + data, + } +} + +export function xorUint8Array(a: Uint8Array, b: Uint8Array): Uint8Array { + const result = new Uint8Array(a.length) + for (let i = 0; i < a.length; i++) { + result[i] = a[i]! ^ b[i]! + } + return result +} + +export function stringToUint8Array(str: string): Uint8Array { + const data = new Uint8Array(str.length) + for (let i = 0; i < str.length; i++) { + data[i] = str.charCodeAt(i) + } + return data +} diff --git a/utils/lt-codes.ts b/utils/lt-codes.ts deleted file mode 100644 index 89c563f..0000000 --- a/utils/lt-codes.ts +++ /dev/null @@ -1,225 +0,0 @@ -interface EncodingMeta { - /** - * Number of original data blocks - */ - k: number - /** - * Data length - */ - length: number - /** - * Checksum - */ - sum: number -} - -interface EncodingBlock extends EncodingMeta { - indices: number[] - data: Uint32Array -} - -export function blockToBinary(block: EncodingBlock): Uint32Array { - const { k, length, sum, indices, data } = block - const header = [ - indices.length, - ...indices, - k, - length, - sum, - ] - const binary = new Uint32Array(header.length + data.length) - binary.set(header) - binary.set(data, header.length) - return binary -} - -export function binaryToBlock(binary: Uint32Array): EncodingBlock { - const degree = binary[0]! - const indices = Array.from(binary.slice(1, degree + 1)) - const [ - k, - length, - sum, - ] = Array.from(binary.slice(degree + 1)) as [number, number, number] - const data = binary.slice(degree + 1 + 3) - return { - k, - length, - sum, - indices, - data, - } -} - -// CRC32 checksum -function checksum(data: Uint32Array): number { - let crc = 0xFFFFFFFF - for (let i = 0; i < data.length; i++) { - crc = crc ^ data[i]! - for (let j = 0; j < 8; j++) { - crc = crc & 1 ? (crc >>> 1) ^ 0xEDB88320 : crc >>> 1 - } - } - return crc ^ 0xFFFFFFFF -} - -// Use Ideal Soliton Distribution to select degree -function getRandomDegree(k: number): number { - const probability = Math.random() - let degree = 1 - - for (let d = 2; d <= k; d++) { - const prob = 1 / (d * (d - 1)) - if (probability < prob) { - degree = d - break - } - } - - return degree -} - -// Randomly select indices of degree number of original data blocks -function getRandomIndices(k: number, degree: number): number[] { - const indices: Set<number> = new Set() - while (indices.size < degree) { - const randomIndex = Math.floor(Math.random() * k) - indices.add(randomIndex) - } - return Array.from(indices) -} - -function xorUint32Array(a: Uint32Array, b: Uint32Array): Uint32Array { - const result = new Uint32Array(a.length) - for (let i = 0; i < a.length; i++) { - result[i] = a[i]! ^ b[i]! - } - return result -} - -function sliceData(data: Uint32Array, blockSize: number): Uint32Array[] { - const blocks: Uint32Array[] = [] - for (let i = 0; i < data.length; i += blockSize) { - const block = new Uint32Array(blockSize) - block.set(data.slice(i, i + blockSize)) - blocks.push(block) - } - return blocks -} - -export function *encodeFountain(data: Uint32Array, indiceSize: number): Generator<EncodingBlock> { - const sum = checksum(data) - const indices = sliceData(data, indiceSize) - const k = indices.length - const meta: EncodingMeta = { - k, - length: data.length, - sum, - } - - while (true) { - const degree = getRandomDegree(k) - const selectedIndices = getRandomIndices(k, degree) - let encodedData = new Uint32Array(indiceSize) - - for (const index of selectedIndices) { - encodedData = xorUint32Array(encodedData, indices[index]!) - } - - yield { - ...meta, - indices: selectedIndices, - data: encodedData, - } - } -} - -export function createDecoder(blocks?: EncodingBlock[]) { - return new LtDecoder(blocks) -} - -export class LtDecoder { - public decodedData: (Uint32Array | undefined)[] = [] - public decodedCount = 0 - public encodedBlocks: Set<EncodingBlock> = new Set() - public meta: EncodingBlock = undefined! - - constructor(blocks?: EncodingBlock[]) { - if (blocks) { - this.addBlock(blocks) - } - } - - // Add blocks and decode them on the fly - addBlock(blocks: EncodingBlock[]): boolean { - if (!blocks.length) { - return false - } - - if (!this.meta) { - this.meta = blocks[0]! - } - - for (const block of blocks) { - this.encodedBlocks.add(block) - } - - for (const block of this.encodedBlocks) { - let { data, indices } = block - - for (const index of indices) { - if (this.decodedData[index] != null) { - data = xorUint32Array(data, this.decodedData[index]!) - indices = indices.filter(i => i !== index) - } - } - - block.data = data - block.indices = indices - - if (indices.length === 1) { - this.decodedData[indices[0]!] = data - this.decodedCount++ - this.encodedBlocks.delete(block) - } - } - - return this.decodedCount === this.meta.k - } - - getDecoded(): Uint32Array | undefined { - if (this.decodedCount !== this.meta.k) { - return - } - if (this.decodedData.some(block => block == null)) { - return - } - const indiceSize = this.meta.data.length - const blocks = this.decodedData as Uint32Array[] - const decodedData = new Uint32Array(this.meta.length) - blocks.forEach((block, i) => { - const start = i * indiceSize - if (start + indiceSize > decodedData.length) { - for (let j = 0; j < decodedData.length - start; j++) { - decodedData[start + j] = block[j]! - } - } - else { - decodedData.set(block, i * indiceSize) - } - }) - const sum = checksum(decodedData) - if (sum !== this.meta.sum) { - throw new Error('Checksum mismatch') - } - return decodedData - } -} - -export function tringToUint32Array(str: string): Uint32Array { - const data = new Uint32Array(str.length) - for (let i = 0; i < str.length; i++) { - data[i] = str.charCodeAt(i) - } - return data -}