| name | filmkit-fujifilm-camera |
| description | Browser-based preset manager and RAW converter for Fujifilm X-series cameras using WebUSB and PTP protocol |
| triggers | ["work with filmkit","fujifilm camera presets","manage fuji camera profiles","webusb fujifilm connection","fujifilm raw conversion browser","filmkit preset management","fuji x-series ptp protocol","filmkit camera integration"] |
FilmKit Fujifilm Camera Skill
Skill by ara.so — Daily 2026 Skills collection.
FilmKit is a browser-based, zero-install preset manager and RAW converter for Fujifilm X-series cameras. It uses WebUSB to communicate via PTP (Picture Transfer Protocol) — the same protocol as Fujifilm X RAW STUDIO — so the camera's own image processor handles RAW-to-JPEG conversion. It runs entirely client-side (hosted on GitHub Pages) and supports desktop and Android.
What FilmKit Does
- Preset Management: Read, edit, and write custom film simulation presets directly on-camera (slots D18E–D1A5 via PTP
GetDevicePropValue / SetDevicePropValue)
- Local Preset Library: Save presets locally, drag-and-drop between camera and local storage
- RAW Conversion & Live Preview: Send RAF files to the camera, receive full-quality JPEGs back
- Preset Detection: Loading a RAF file auto-detects which preset was used to shoot it
- Import/Export: Presets as files, links, or text paste
- Mobile Support: Works on Android via Chrome's WebUSB support
Requirements
- Chromium-based browser (Google Chrome, Edge, Brave) on desktop or Android — WebUSB is required
- Fujifilm X-series camera connected via USB (tested on X100VI; likely works on X-T5, X-H2, X-T30, etc.)
- Linux udev rule (if running Chrome in Flatpak):
SUBSYSTEM=="usb", ATTR{idVendor}=="04cb", MODE="0666"
Reload rules after adding:
sudo udevadm control --reload-rules && sudo udevadm trigger
Installation / Setup (Development)
FilmKit is a static TypeScript app. To run locally:
git clone https://github.com/eggricesoy/filmkit.git
cd filmkit
npm install
npm run dev
Build for production:
npm run build
The built output is a static site — no server required. Open in Chrome at http://localhost:5173 (or wherever Vite serves it).
Architecture Overview
PTP over WebUSB
FilmKit speaks PTP (Picture Transfer Protocol) directly over USB bulk transfers. Key operations:
| PTP Operation | Purpose |
|---|
GetDevicePropValue | Read a camera preset property |
SetDevicePropValue | Write a camera preset property |
InitiateOpenCapture | Start RAW conversion session |
SendObject | Send RAF file to camera |
GetObject | Retrieve converted JPEG from camera |
Preset Property Codes
Fujifilm X-series cameras expose film simulation parameters as device properties in the range 0xD18E–0xD1A5:
const PROP_FILM_SIMULATION = 0xD18E;
const PROP_GRAIN_EFFECT = 0xD18F;
const PROP_COLOR_CHROME = 0xD190;
const PROP_WHITE_BALANCE = 0xD191;
const PROP_COLOR_TEMP = 0xD192;
const PROP_DYNAMIC_RANGE = 0xD193;
const PROP_HIGHLIGHT_TONE = 0xD194;
const PROP_SHADOW_TONE = 0xD195;
const PROP_COLOR = 0xD196;
const PROP_SHARPNESS = 0xD197;
const PROP_HIGH_ISO_NR = 0xD198;
const PROP_CLARITY = 0xD199;
Native Profile Format
The camera's native d185 profile is 625 bytes and uses different field indices/encoding from RAF file metadata. FilmKit uses a patch-based approach:
function applyPresetPatch(baseProfile: Uint8Array, changes: PresetChanges): Uint8Array {
const patched = new Uint8Array(baseProfile);
for (const [fieldIndex, encodedValue] of Object.entries(changes)) {
writeFieldToProfile(patched, parseInt(fieldIndex), encodedValue);
}
return patched;
}
Key Code Patterns
WebUSB Connection
async function connectCamera(): Promise<USBDevice> {
const device = await navigator.usb.requestDevice({
filters: [{ vendorId: 0x04CB }]
});
await device.open();
await device.selectConfiguration(1);
await device.claimInterface(0);
return device;
}
Sending a PTP Command
function buildPTPCommand(
operationCode: number,
transactionId: number,
params: number[] = []
): ArrayBuffer {
const paramCount = params.length;
const length = 12 + paramCount * 4;
const buffer = new ArrayBuffer(length);
const view = new DataView(buffer);
view.setUint32(0, length, true);
view.setUint16(4, 0x0001, true);
view.setUint16(6, operationCode, true);
view.setUint32(8, transactionId, true);
params.forEach((p, i) => {
view.setUint32(12 + i * 4, p, true);
});
return buffer;
}
async function ptpTransaction(): <{ : ; ?: }> {
endpointOut = ;
endpointIn = ;
cmd = (operationCode, transactionId, params);
device.(endpointOut, cmd);
(outData) {
device.(endpointOut, outData);
}
dataResult = device.(endpointIn, );
respResult = device.(endpointIn, );
respView = (respResult.!.);
responseCode = respView.(, );
{ responseCode, : dataResult.?. };
}
Reading a Preset Property
async function getDevicePropValue(
device: USBDevice,
propCode: number,
txId: number
): Promise<DataView> {
const PTP_OP_GET_DEVICE_PROP_VALUE = 0x1015;
const { data } = await ptpTransaction(
device,
PTP_OP_GET_DEVICE_PROP_VALUE,
txId,
[propCode]
);
if (!data) throw new Error(`No data for prop 0x${propCode.toString(16)}`);
return new DataView(data, 12);
}
const filmSimView = await getDevicePropValue(device, 0xD18E, txId++);
const filmSimValue = filmSimView.getUint16(0, true);
console.log('Film simulation code:', filmSimValue);
Writing a Preset Property
async function setDevicePropValue(
device: USBDevice,
propCode: number,
value: number,
byteSize: 1 | 2 | 4,
txId: number
): Promise<void> {
const PTP_OP_SET_DEVICE_PROP_VALUE = 0x1016;
const dataLength = 12 + byteSize;
const dataBuffer = new ArrayBuffer(dataLength);
const view = new DataView(dataBuffer);
view.setUint32(0, dataLength, true);
view.setUint16(4, 0x0002, true);
view.setUint16(6, PTP_OP_SET_DEVICE_PROP_VALUE, true);
view.setUint32(8, txId, true);
if (byteSize === 1) view.setUint8(12, value);
else if (byteSize === ) view.(, value, );
(byteSize === ) view.(, value, );
(
device,
,
txId,
[propCode],
dataBuffer
);
}
(device, , , , txId++);
(device, , , , txId++);
HighIsoNR Special Encoding
HighIsoNR uses a non-linear proprietary encoding — do not write raw values directly:
const HIGH_ISO_NR_ENCODE: Record<number, number> = {
[-4]: 0x00,
[-3]: 0x01,
[-2]: 0x02,
[-1]: 0x03,
[0]: 0x04,
[1]: 0x08,
[2]: 0x0C,
[3]: 0x10,
[4]: 0x14,
};
function encodeHighIsoNR(userValue: number): number {
const encoded = HIGH_ISO_NR_ENCODE[userValue];
if (encoded === undefined) throw new Error(`Invalid HighIsoNR value: ${userValue}`);
return encoded;
}
await setDevicePropValue(device, 0xD198, encodeHighIsoNR(2), 1, txId++);
Conditional Writes (Monochrome Film Simulations)
Monochrome film simulations reject Color property writes — guard against this:
const MONOCHROME_SIMULATIONS = new Set([
0x0009,
0x000A,
0x000B,
0x000C,
0x0012,
0x0013,
0x0014,
0x0015,
0x001A,
]);
async function writePreset(device: USBDevice, preset: Preset, txId: number): Promise<number> {
const isMonochrome = MONOCHROME_SIMULATIONS.has(preset.filmSimulation);
await setDevicePropValue(device, 0xD18E, preset.filmSimulation, 2, txId++);
if (!isMonochrome) {
await setDevicePropValue(device, 0xD196, preset.color, 2, txId++);
}
await setDevicePropValue(device, , (preset.), , txId++);
txId;
}
RAW Conversion Flow
async function convertRAW(
device: USBDevice,
rafData: ArrayBuffer,
preset: Preset,
txId: number
): Promise<ArrayBuffer> {
txId = await writePreset(device, preset, txId);
await ptpTransaction(device, 0x101C, txId++);
const sendObjectOp = 0x100D;
await ptpTransaction(device, sendObjectOp, txId++, [], rafData);
const getObjectOp = 0x1009;
const { data: jpegData } = await ptpTransaction(device, getObjectOp, txId++);
if (!jpegData) throw new Error('No JPEG returned from camera');
return jpegData;
}
Preset Import/Export Format
Presets are exported as structured data (JSON or encoded strings). When importing:
interface FilmKitPreset {
name: string;
filmSimulation: number;
grainEffect: number;
colorChrome: number;
whiteBalance: number;
colorTemperature?: number;
dynamicRange: number;
highlightTone: number;
shadowTone: number;
color: number;
sharpness: number;
highIsoNR: number;
clarity: number;
}
function exportPresetAsLink(preset: FilmKitPreset): string {
const encoded = btoa(JSON.stringify(preset));
return `https://filmkit.eggrice.soy/?preset=${encoded}`;
}
function importPreset(input: string): FilmKitPreset {
{
url = (input);
param = url..();
(param) .((param));
} {}
{ .((input)); } {}
{ .(input); } {}
();
}
Capturing USB Traffic for New Camera Support
To help add support for a new Fujifilm X-series camera:
- Install Wireshark with USBPcap
- Capture on USB bus:
USBPcap1:\\.\USBPcap1
- Filter:
usb.transfer_type == 0x02 (bulk transfers = PTP traffic)
- Perform these actions in X RAW STUDIO while capturing:
- Profile read (connect and let app read camera state)
- Preset save (change all preset values, save to a slot)
- RAW conversion (load RAF, convert with a preset)
- Save each capture as
.pcapng
- Open a GitHub issue with: camera model, firmware version, all three
.pcapng files, and the parameter values used
Troubleshooting
WebUSB Not Available
- Must use Chrome or Chromium-based browser (Firefox does not support WebUSB)
- On Android, use Chrome (not Firefox for Android)
- Check
chrome://flags — ensure "Disable WebUSB" is not enabled
Camera Not Detected
- Ensure the camera is in USB mode (MTP or PTP, not Mass Storage)
- On Linux without Flatpak: check that your user is in the
plugdev group: sudo usermod -aG plugdev $USER
- On Linux with Flatpak Chrome: add udev rule for vendor
04cb and reload
Permission Denied on Linux
lsusb | grep -i fuji
ls -la /dev/bus/usb/$(lsusb | grep -i fuji | awk '{print $2"/"$4}' | tr -d ':')
PTP Transaction Errors
- Ensure no other app (X RAW STUDIO, Capture One, etc.) is connected to the camera simultaneously
- Only one WebUSB consumer can hold the interface at a time
- Disconnect and reconnect the camera if the interface gets stuck
Preset Write Rejected
- Writing
Color property on a monochrome film simulation will be rejected — this is expected behavior (see conditional writes above)
- Writing
Color Temperature requires WB mode set to 0x0012 first
HighIsoNR must use the non-linear encoded value, not the raw user-facing value
Debug Log
In the FilmKit UI, scroll to the Debug section at the bottom of the right sidebar → click Copy Log → paste into a GitHub issue for bug reports.
Key Links