Files
RecipesCam/docker/frontend/shared/utils/exifWrite.ts
T
3dtours 8c6e7930db Add self-contained docker/ stack for the web UI
`docker/` now holds the whole web build — frontend (Vite + React + CanvasKit),
backend (Fastify + SQLite) and the compose file — so the folder can be moved to
another machine and run without the React Native project:

    cd docker && cp .env.example .env && docker compose up -d --build

Only `${WEB_PORT:-8090}` is published; nginx serves the SPA and proxies /api to
the `api` container over Docker's DNS. Photos never reach the server.

The shared render code is vendored into `docker/frontend/shared/` and aliased to
a CanvasKit shim, so the app's own frameUtils/toneShader/jpegDpi run unchanged.

Fix the all-black render on GPU surfaces: `MakeWebGLCanvasSurface` creates a
separate WebGL context per call, and a texture from one context cannot be
sampled by a surface on another — so any pass that drew a snapshot onto a second
surface (output sharpen, screen sharpen, polaroid/wallframe cards) came out
solid black, while the raster fallback was correct. Use one shared
GrDirectContext + MakeRenderTarget instead.

Verified in headless Chromium against the running stack: 12MP JPEG in, preview
mean=120.5 sd=60.5, export 2048x1536 mean=107.2 sd=62.1, JFIF density 300/300,
EXIF present, no console errors; health/signup/login/me/recipes all 2xx through
the nginx proxy.
2026-09-17 17:43:03 +07:00

476 lines
20 KiB
TypeScript

// Pure JPEG/EXIF writer — no imports, so the codec can be exercised directly.
//
// Skia re-encodes the photo, which drops the original EXIF block (see
// jpegDpi.ts). This rebuilds the APP1/EXIF segment: it reads the SOURCE
// photo's TIFF, keeps every tag it does not own, and stamps the fields this app
// is responsible for — software + version, capture date + zone, GPS, pixel
// dimensions — plus the baseline tags a conforming EXIF block must carry
// (ExifVersion, FlashPixVersion, ComponentsConfiguration, ColorSpace, Interop).
// Same in-place byte approach as jpegDpi.ts: no dependency, no native module.
//
// ponytail: IFD1 (the embedded thumbnail) and MakerNote are dropped. A carried
// thumbnail would show the PRE-export pixels, and MakerNote holds absolute
// offsets that a relocated TIFF invalidates. Read them back when a use case
// needs them.
// Stamped into every file this app writes (kept in step with app.json).
export const SOFTWARE = 'RecipesCam 1.2';
export interface ExifStamp {
software?: string;
// Shutter instant. null/undefined = keep the loaded photo's own
// DateTimeOriginal and only fill it in when the source has none.
dateTime?: Date | null;
gps?: { latitude: number; longitude: number } | null;
// Place name shown on the Gallery frame (EXIF 0x889d). Stock photos carry it
// whenever the capture was geotagged; ours have to state it from the same
// info the watermark prints.
locationName?: string | null;
width?: number | null;
height?: number | null;
dpi?: number | null;
// Device identity for the Gallery watermark blob (ref. watermarkBlob). Only
// used when the source photo carries no blob of its own.
device?: DeviceIdentity | null;
}
/** Raw device identity for the HyperOS Gallery watermark tag (0x889e). */
export interface DeviceIdentity {
device: string; // ro.product.device codename, e.g. "aurora"
marketName: string; // ro.product.marketname, e.g. "Xiaomi 14 Ultra"
manufacturer: string; // Build.MANUFACTURER, e.g. "Xiaomi"
model: string; // Build.MODEL — fallback when marketName is empty
}
interface Entry {
tag: number;
type: number;
count: number;
data: Uint8Array;
ref?: 'exif' | 'gps' | 'interop'; // IFD pointer entry — value patched during layout
}
const BYTE = 1, ASCII = 2, SHORT = 3, LONG = 4, RATIONAL = 5, UNDEFINED = 7, SLONG = 9, SRATIONAL = 10;
const TYPE_SIZE: Record<number, number> = {
[BYTE]: 1, [ASCII]: 1, [SHORT]: 2, [LONG]: 4, [RATIONAL]: 8,
[UNDEFINED]: 1, [SLONG]: 4, [SRATIONAL]: 8,
};
// Tags this module owns, or that would dangle once the TIFF is rebuilt.
const DROP = new Set<number>([
0x0112, // Orientation — the exported pixels are already upright (stamped back as 1)
0x0100, 0x0101, // ImageWidth / ImageLength (stamped from the real export size)
0x011a, 0x011b, 0x0128, // XResolution / YResolution / ResolutionUnit (stamped when dpi is set)
0x0131, 0x0132, // Software / DateTime
0x8769, 0x8825, 0xa005, 0x014a, // Exif / GPS / Interop / SubIFD pointers (rebuilt)
0x0200, 0x0201, 0x0202, // thumbnail offset/length
0x927c, // MakerNote — internal offsets do not survive relocation
]);
// --- little-endian writers -------------------------------------------------
function u16w(b: Uint8Array, p: number, v: number): void {
b[p] = v & 0xff;
b[p + 1] = (v >> 8) & 0xff;
}
function u32w(b: Uint8Array, p: number, v: number): void {
b[p] = v & 0xff;
b[p + 1] = (v >> 8) & 0xff;
b[p + 2] = (v >> 16) & 0xff;
b[p + 3] = (v >> 24) & 0xff;
}
// --- entry builders --------------------------------------------------------
// ASCII-typed byte string, NUL-terminated as EXIF requires. The bytes are UTF-8:
// a place name ("TAM KỲ, ĐÀ NẴNG") or a market name can carry accents, and the
// charCodeAt & 0xff shortcut would mangle them into latin-1. Pure-ASCII text
// encodes byte-for-byte the same as before.
function asciiEntry(tag: number, text: string): Entry {
const bytes: number[] = [];
for (const ch of text) {
const cp = ch.codePointAt(0) as number;
if (cp < 0x80) bytes.push(cp);
else if (cp < 0x800) bytes.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f));
else if (cp < 0x10000) bytes.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
else bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
}
bytes.push(0);
return { tag, type: ASCII, count: bytes.length, data: new Uint8Array(bytes) };
}
function shortEntry(tag: number, v: number): Entry {
const data = new Uint8Array(2);
u16w(data, 0, v);
return { tag, type: SHORT, count: 1, data };
}
// UNDEFINED value: a raw byte string whose length IS the count (ExifVersion etc).
function undefinedEntry(tag: number, text: string): Entry {
const data = new Uint8Array(text.length);
for (let i = 0; i < text.length; i++) data[i] = text.charCodeAt(i) & 0xff;
return { tag, type: UNDEFINED, count: data.length, data };
}
function longEntry(tag: number, v: number): Entry {
const data = new Uint8Array(4);
u32w(data, 0, v);
return { tag, type: LONG, count: 1, data };
}
function rationalEntry(tag: number, num: number, den: number, type = RATIONAL): Entry {
const data = new Uint8Array(8);
u32w(data, 0, num);
u32w(data, 4, den);
return { tag, type, count: 1, data };
}
// GPS coordinate: 3 RATIONALs (degrees, minutes, seconds), denominator 10000
// for the seconds so ~0.1 m survives.
function gpsCoordEntry(tag: number, value: number): Entry {
const abs = Math.abs(value);
let deg = Math.floor(abs);
let min = Math.floor((abs - deg) * 60);
let sec = Math.round(((abs - deg) * 60 - min) * 60 * 10000);
if (sec >= 600000) { sec = 0; min += 1; }
if (min >= 60) { min = 0; deg += 1; }
const data = new Uint8Array(24);
u32w(data, 0, deg); u32w(data, 4, 1);
u32w(data, 8, min); u32w(data, 12, 1);
u32w(data, 16, sec); u32w(data, 20, 10000);
return { tag, type: RATIONAL, count: 3, data };
}
// A big-endian source TIFF hands us big-endian value bytes; they are re-emitted
// into a little-endian TIFF, so every numeric value must be swapped first
// (ASCII/BYTE/UNDEFINED are byte strings and stay as they are).
function swapToLittleEndian(data: Uint8Array, type: number): Uint8Array {
const unit = type === SHORT ? 2 : type === LONG || type === SLONG ? 4 : type === RATIONAL || type === SRATIONAL ? 4 : 0;
if (!unit) return data;
// `new Uint8Array(data)` (not data.slice()) — the input may be a Node Buffer,
// whose slice() aliases the source and would corrupt the caller's bytes.
const out = new Uint8Array(data);
for (let p = 0; p + unit <= out.length; p += unit) {
for (let i = 0; i < unit >> 1; i++) {
const a = out[p + i];
out[p + i] = out[p + unit - 1 - i];
out[p + unit - 1 - i] = a;
}
}
return out;
}
function asciiValue(e: Entry | undefined): string | null {
if (!e || e.type !== ASCII || e.data.length === 0) return null;
let s = '';
for (let i = 0; i < e.data.length && e.data[i] !== 0; i++) s += String.fromCharCode(e.data[i]);
return s || null;
}
// "YYYY:MM:DD HH:MM:SS" — the EXIF date format (colons, not dashes).
function exifDate(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}:${p(d.getMonth() + 1)}:${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
// "±HH:MM" — the OffsetTime* tag format. Framing tools read it to place a
// capture correctly, and it is what makes the date unambiguous without GPS.
function tzOffset(d: Date): string {
const mins = -d.getTimezoneOffset();
const a = Math.abs(mins);
const p = (n: number) => String(n).padStart(2, '0');
return `${mins < 0 ? '-' : '+'}${p(Math.floor(a / 60))}:${p(a % 60)}`;
}
// The watermark blob HyperOS Gallery reads back (EXIF tag 0x889e) before it
// will open its watermark editor: without it, a photo edited here fails with
// "cannot recognize the parameters". Only the stock camera app writes this tag
// — CameraX/HAL never does — so a camera capture has to synthesize it from the
// same props the stock app derives it from. Shape matches the stock bytes:
// {"buildDevice":"aurora",...,"waterLogo":"XIAOMI","waterName":"14 Ultra"}.
function watermarkBlob(d: DeviceIdentity): string {
const brand = d.manufacturer.trim();
const market = d.marketName.trim();
// "Xiaomi 14 Ultra" -> "14 Ultra": Gallery prints waterName, not the brand.
const waterName = market.toLowerCase().startsWith(`${brand.toLowerCase()} `)
? market.slice(brand.length + 1)
: market || d.model;
return JSON.stringify({
buildDevice: d.device,
customize: '',
cvLensName: '',
filterName: '',
livephotoInfo: '',
version: 2,
waterLogo: brand.toUpperCase(),
waterName,
});
}
// --- parse -----------------------------------------------------------------
interface ParsedTiff {
ifd0: Entry[];
exif: Entry[];
gps: Entry[];
interop: Entry[];
}
function parseTiff(tiff: Uint8Array): ParsedTiff {
const none: ParsedTiff = { ifd0: [], exif: [], gps: [], interop: [] };
if (tiff.length < 8) return none;
const le = tiff[0] === 0x49 && tiff[1] === 0x49;
const be = tiff[0] === 0x4d && tiff[1] === 0x4d;
if (!le && !be) return none;
const u16 = (p: number): number => (le ? tiff[p] | (tiff[p + 1] << 8) : (tiff[p] << 8) | tiff[p + 1]);
const u32 = (p: number): number =>
le
? (tiff[p] | (tiff[p + 1] << 8) | (tiff[p + 2] << 16) | (tiff[p + 3] << 24)) >>> 0
: ((tiff[p] << 24) | (tiff[p + 1] << 16) | (tiff[p + 2] << 8) | tiff[p + 3]) >>> 0;
const read = (off: number): { list: Entry[]; ptr: Record<number, number> } => {
const out: { list: Entry[]; ptr: Record<number, number> } = { list: [], ptr: {} };
if (off <= 0 || off + 2 > tiff.length) return out;
const n = u16(off);
for (let i = 0; i < n; i++) {
const p = off + 2 + i * 12;
if (p + 12 > tiff.length) break;
const tag = u16(p);
const type = u16(p + 2);
const count = u32(p + 4);
if (tag === 0x8769 || tag === 0x8825 || tag === 0xa005) {
out.ptr[tag] = u32(p + 8); // sub-IFD pointers are rebuilt, never copied
continue;
}
const size = TYPE_SIZE[type];
if (!size || DROP.has(tag)) continue;
const len = size * count;
if (len <= 0 || len > 4 * 1024 * 1024) continue; // ignore absurd/overflowing counts
if (len <= 4) {
out.list.push({ tag, type, count, data: be ? swapToLittleEndian(tiff.slice(p + 8, p + 8 + len), type) : tiff.slice(p + 8, p + 8 + len) });
} else {
const o = u32(p + 8);
if (o + len > tiff.length) continue;
const raw = tiff.slice(o, o + len);
out.list.push({ tag, type, count, data: be ? swapToLittleEndian(raw, type) : raw });
}
}
return out;
};
const a = read(u32(4));
const exifRead = a.ptr[0x8769] ? read(a.ptr[0x8769]) : { list: [], ptr: {} };
return {
ifd0: a.list,
exif: exifRead.list,
gps: a.ptr[0x8825] ? read(a.ptr[0x8825]).list : [],
interop: exifRead.ptr[0xa005] ? read(exifRead.ptr[0xa005]).list : [],
};
}
// Locate the APP1/EXIF segment: start/end bound the whole segment, tiff the
// payload after the "Exif\0\0" signature.
function findExifSegment(jpeg: Uint8Array): { start: number; end: number; tiff: Uint8Array } | null {
if (jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return null;
let i = 2;
while (i + 1 < jpeg.length && jpeg[i] === 0xff) {
const start = i;
while (jpeg[i] === 0xff) i++;
const marker = jpeg[i];
i++;
if (marker === 0xd9 || marker === 0xda) return null; // EOI / SOS — headers only
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
if (i + 2 > jpeg.length) return null;
const len = (jpeg[i] << 8) | jpeg[i + 1];
if (len < 2 || i + len > jpeg.length) return null;
const dataStart = i + 2;
if (
marker === 0xe1 && len >= 8 &&
jpeg[dataStart] === 0x45 && jpeg[dataStart + 1] === 0x78 && jpeg[dataStart + 2] === 0x69 &&
jpeg[dataStart + 3] === 0x66 && jpeg[dataStart + 4] === 0x00 && jpeg[dataStart + 5] === 0x00
) {
return { start, end: i + len, tiff: jpeg.subarray(dataStart + 6, i + len) };
}
i += len;
}
return null;
}
// --- serialize -------------------------------------------------------------
function serializeTiff(ifd0: Entry[], exif: Entry[], gps: Entry[], interop: Entry[]): Uint8Array {
const ifdLen = (n: number) => 2 + n * 12 + 4;
const outLen = (es: Entry[]) => {
let s = 0;
for (const e of es) if (e.data.length > 4) s += (e.data.length + 1) & ~1;
return s;
};
const o0 = 8;
const d0 = o0 + ifdLen(ifd0.length);
const oE = d0 + outLen(ifd0);
const dE = oE + ifdLen(exif.length);
const oG = dE + outLen(exif);
const dG = oG + ifdLen(gps.length);
const oI = dG + outLen(gps);
const dI = oI + ifdLen(interop.length);
const t = new Uint8Array(dI + outLen(interop));
t[0] = 0x49; t[1] = 0x49; t[2] = 0x2a; t[3] = 0x00; // "II", 42
u32w(t, 4, o0);
const writeIfd = (off: number, es: Entry[], dataOff: number): void => {
u16w(t, off, es.length);
let c = dataOff;
es.forEach((e, i) => {
const p = off + 2 + i * 12;
u16w(t, p, e.tag);
u16w(t, p + 2, e.type);
u32w(t, p + 4, e.count);
if (e.ref === 'exif') { u32w(t, p + 8, oE); return; }
if (e.ref === 'gps') { u32w(t, p + 8, oG); return; }
if (e.ref === 'interop') { u32w(t, p + 8, oI); return; }
if (e.data.length <= 4) { t.set(e.data, p + 8); return; }
u32w(t, p + 8, c);
t.set(e.data, c);
c += (e.data.length + 1) & ~1; // values start on a word boundary
});
u32w(t, off + 2 + es.length * 12, 0); // no IFD1
};
writeIfd(o0, ifd0, d0);
writeIfd(oE, exif, dE);
writeIfd(oG, gps, dG);
writeIfd(oI, interop, dI);
return t;
}
/**
* Rebuilds the JPEG's APP1/EXIF block: `sourceJpeg` (the loaded photo) donates
* every tag this module does not own; `stamp` sets the ones the app owns.
* Returns a new array; input is never modified. A JPEG that cannot take the
* segment (not a JPEG, EXIF larger than 64 KB) comes back untouched.
*/
export function writeJpegExif(jpeg: Uint8Array, stamp: ExifStamp, sourceJpeg?: Uint8Array | null): Uint8Array {
if (jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return jpeg;
// An unreadable source (content:// uri, IO error) must not wipe the tags the
// jpeg already carries, so fall back to the jpeg's own EXIF block.
const src = findExifSegment(sourceJpeg && sourceJpeg.length > 4 ? sourceJpeg : jpeg);
const parsed = src ? parseTiff(src.tiff) : { ifd0: [], exif: [], gps: [], interop: [] };
const ifd0 = new Map(parsed.ifd0.map((e) => [e.tag, e]));
const exif = new Map(parsed.exif.map((e) => [e.tag, e]));
const gps = new Map(parsed.gps.map((e) => [e.tag, e]));
const interop = new Map(parsed.interop.map((e) => [e.tag, e]));
const now = exifDate(stamp.dateTime ?? new Date());
const keepSourceDate = !stamp.dateTime;
const sourceDate = asciiValue(ifd0.get(0x0132)) ?? asciiValue(exif.get(0x9003));
const dateStr = keepSourceDate ? sourceDate ?? now : now;
if (!keepSourceDate || !ifd0.has(0x0132)) ifd0.set(0x0132, asciiEntry(0x0132, dateStr));
if (!keepSourceDate || !exif.has(0x9003)) exif.set(0x9003, asciiEntry(0x9003, dateStr));
if (!keepSourceDate || !exif.has(0x9004)) exif.set(0x9004, asciiEntry(0x9004, dateStr));
if (!keepSourceDate) {
// We own the timestamp for camera shots, so we also state its zone.
const off = asciiEntry(0x9010, tzOffset(stamp.dateTime as Date));
ifd0.set(0x9010, off); // OffsetTime (0th IFD)
exif.set(0x9011, { ...off, tag: 0x9011 }); // OffsetTimeOriginal
exif.set(0x9012, { ...off, tag: 0x9012 }); // OffsetTimeDigitized
}
ifd0.set(0x0131, asciiEntry(0x0131, stamp.software ?? SOFTWARE));
ifd0.set(0x0112, shortEntry(0x0112, 1)); // upright — the pixels are already straight
// Baseline tags a conforming EXIF block is expected to carry. Framing apps
// reject a file that omits them, and HALs (the emulator's especially) do not
// always supply them, so fill the gaps and keep whatever the source had.
if (!exif.has(0x9000)) exif.set(0x9000, undefinedEntry(0x9000, '0230')); // ExifVersion
if (!exif.has(0xa000)) exif.set(0xa000, undefinedEntry(0xa000, '0100')); // FlashPixVersion
if (!exif.has(0x9101)) exif.set(0x9101, { tag: 0x9101, type: UNDEFINED, count: 4, data: new Uint8Array([1, 2, 3, 0]) }); // YCbCr
if (!exif.has(0xa001)) exif.set(0xa001, shortEntry(0xa001, 65535)); // ColorSpace = uncalibrated
if (!interop.has(0x0001)) interop.set(0x0001, asciiEntry(0x0001, 'R98')); // InteropIndex
if (!interop.has(0x0002)) interop.set(0x0002, undefinedEntry(0x0002, '0100')); // InteropVersion
if (stamp.width && stamp.height) {
ifd0.set(0x0100, longEntry(0x0100, stamp.width));
ifd0.set(0x0101, longEntry(0x0101, stamp.height));
exif.set(0xa002, longEntry(0xa002, stamp.width)); // PixelXDimension
exif.set(0xa003, longEntry(0xa003, stamp.height)); // PixelYDimension
}
if (stamp.dpi && stamp.dpi > 0) {
ifd0.set(0x011a, rationalEntry(0x011a, stamp.dpi, 1));
ifd0.set(0x011b, rationalEntry(0x011b, stamp.dpi, 1));
ifd0.set(0x0128, shortEntry(0x0128, 2)); // resolution unit = inch
}
// HyperOS Gallery refuses to open its watermark editor without 0x889e, and a
// CameraX capture never carries it. Never overwrite a blob the source has.
const xiaomi = !!stamp.device && /xiaomi|redmi|poco/i.test(stamp.device.manufacturer);
if (stamp.device && xiaomi && !exif.has(0x889e)) {
exif.set(0x889e, asciiEntry(0x889e, watermarkBlob(stamp.device)));
}
// Stock Xiaomi photos name the device in 0x9a00 and in Model (0x0110) as the
// MARKET name ("Xiaomi 14 Ultra"); the framing tool matches those against its
// known-device list and reports an unknown device otherwise. A CameraX capture
// only has the raw codename, so state the market name. A source that already
// carries 0x9a00 is a stock photo of some other device — leave it alone.
if (stamp.device && xiaomi && !ifd0.has(0x9a00)) {
const market = (stamp.device.marketName || stamp.device.model).trim();
if (market) {
ifd0.set(0x9a00, asciiEntry(0x9a00, market));
ifd0.set(0x0110, asciiEntry(0x0110, market));
}
}
// Place name the frame prints (0x889d) — stock writes it in both IFDs.
const place = stamp.locationName?.trim();
if (place) {
if (!ifd0.has(0x889d)) ifd0.set(0x889d, asciiEntry(0x889d, place));
if (!exif.has(0x889d)) exif.set(0x889d, asciiEntry(0x889d, place));
}
if (stamp.gps) {
const { latitude: lat, longitude: lon } = stamp.gps;
gps.set(0x0001, asciiEntry(0x0001, lat >= 0 ? 'N' : 'S'));
gps.set(0x0002, gpsCoordEntry(0x0002, lat));
gps.set(0x0003, asciiEntry(0x0003, lon >= 0 ? 'E' : 'W'));
gps.set(0x0004, gpsCoordEntry(0x0004, lon));
}
const list0 = [...ifd0.values()];
if (exif.size > 0) list0.push({ tag: 0x8769, type: LONG, count: 1, data: new Uint8Array(4), ref: 'exif' });
if (gps.size > 0) list0.push({ tag: 0x8825, type: LONG, count: 1, data: new Uint8Array(4), ref: 'gps' });
list0.sort((a, b) => a.tag - b.tag);
const listE = [...exif.values()].sort((a, b) => a.tag - b.tag);
const listG = [...gps.values()].sort((a, b) => a.tag - b.tag);
if (interop.size > 0) listE.push({ tag: 0xa005, type: LONG, count: 1, data: new Uint8Array(4), ref: 'interop' });
listE.sort((a, b) => a.tag - b.tag);
const listI = [...interop.values()].sort((a, b) => a.tag - b.tag);
const tiff = serializeTiff(list0, listE, listG, listI);
const payload = 2 + 6 + tiff.length;
if (payload > 0xffff) return jpeg; // APP1 length is a 16-bit field
const app1 = new Uint8Array(2 + payload);
app1[0] = 0xff; app1[1] = 0xe1;
// APP1 length is a big-endian 16-bit field (the TIFF block after it is little-endian)
app1[2] = (payload >> 8) & 0xff;
app1[3] = payload & 0xff;
app1.set([0x45, 0x78, 0x69, 0x66, 0x00, 0x00], 4); // "Exif\0\0"
app1.set(tiff, 10);
const old = findExifSegment(jpeg);
const head = old ? jpeg.subarray(0, old.start) : jpeg.subarray(0, 2);
const tail = old ? jpeg.subarray(old.end) : jpeg.subarray(2);
const out = new Uint8Array(head.length + app1.length + tail.length);
out.set(head, 0);
out.set(app1, head.length);
out.set(tail, head.length + app1.length);
return out;
}