filmkit-fujifilm-camera

Browser-based preset manager and RAW converter for Fujifilm X-series cameras using WebUSB and PTP protocol

INSTALLATION
npx skills add https://github.com/aradotso/trending-skills --skill filmkit-fujifilm-camera
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

$27

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):
# /etc/udev/rules.d/99-fujifilm.rules

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 0xD18E0xD1A5:

// Example property codes (from QUICK_REFERENCE.md)

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; // Non-linear encoding!

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:

// Conceptual patch approach

function applyPresetPatch(baseProfile: Uint8Array, changes: PresetChanges): Uint8Array {

  // Copy base profile byte-for-byte

  const patched = new Uint8Array(baseProfile);

  // Only overwrite fields the user changed

  // This preserves EXIF sentinel values in unchanged fields

  for (const [fieldIndex, encodedValue] of Object.entries(changes)) {

    writeFieldToProfile(patched, parseInt(fieldIndex), encodedValue);

  }

  return patched;

}

Key Code Patterns

WebUSB Connection

// Request access to the Fujifilm camera

async function connectCamera(): Promise<USBDevice> {

  const device = await navigator.usb.requestDevice({

    filters: [{ vendorId: 0x04CB }] // Fujifilm vendor ID

  });

  await device.open();

  await device.selectConfiguration(1);

  await device.claimInterface(0);

  return device;

}

Sending a PTP Command

// PTP command packet structure

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);        // Length

  view.setUint16(4, 0x0001, true);        // Type: Command

  view.setUint16(6, operationCode, true); // Operation code

  view.setUint32(8, transactionId, true); // Transaction ID

  params.forEach((p, i) => {

    view.setUint32(12 + i * 4, p, true);

  });

  return buffer;

}

// Send a PTP operation and read response

async function ptpTransaction(

  device: USBDevice,

  operationCode: number,

  transactionId: number,

  params: number[] = [],

  outData?: ArrayBuffer

): Promise<{ responseCode: number; data?: ArrayBuffer }> {

  const endpointOut = 0x02; // Bulk OUT

  const endpointIn  = 0x81; // Bulk IN

  // Send command

  const cmd = buildPTPCommand(operationCode, transactionId, params);

  await device.transferOut(endpointOut, cmd);

  // Send data phase if present

  if (outData) {

    await device.transferOut(endpointOut, outData);

  }

  // Read data response (if expected)

  const dataResult = await device.transferIn(endpointIn, 512);

  // Read response packet

  const respResult = await device.transferIn(endpointIn, 32);

  const respView = new DataView(respResult.data!.buffer);

  const responseCode = respView.getUint16(6, true);

  return { responseCode, data: dataResult.data?.buffer };

}

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)}`);

  // PTP data container: 12-byte header, then payload

  return new DataView(data, 12);

}

// Example: read film simulation

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;

  // Build data container

  const dataLength = 12 + byteSize;

  const dataBuffer = new ArrayBuffer(dataLength);

  const view = new DataView(dataBuffer);

  view.setUint32(0, dataLength, true); // Length

  view.setUint16(4, 0x0002, true);     // Type: Data

  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 === 2) view.setUint16(12, value, true);

  else if (byteSize === 4) view.setUint32(12, value, true);

  await ptpTransaction(

    device,

    PTP_OP_SET_DEVICE_PROP_VALUE,

    txId,

    [propCode],

    dataBuffer

  );

}

// Example: set White Balance to Color Temperature mode

await setDevicePropValue(device, 0xD191, 0x0012, 2, txId++);

// Now safe to set Color Temperature value

await setDevicePropValue(device, 0xD192, 4500, 2, txId++);

HighIsoNR Special Encoding

HighIsoNR uses a non-linear proprietary encoding — do not write raw values directly:

// HighIsoNR encoding map (reverse-engineered via Wireshark)

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;

}

// Usage

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, // ACROS

  0x000A, // ACROS+Ye

  0x000B, // ACROS+R

  0x000C, // ACROS+G

  0x0012, // Monochrome

  0x0013, // Monochrome+Ye

  0x0014, // Monochrome+R

  0x0015, // Monochrome+G

  0x001A, // Eterna Cinema BW

]);

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, 0xD198, encodeHighIsoNR(preset.highIsoNR), 1, txId++);

  // ... write other properties

  return txId;

}

RAW Conversion Flow

async function convertRAW(

  device: USBDevice,

  rafData: ArrayBuffer,

  preset: Preset,

  txId: number

): Promise<ArrayBuffer> {

  // 1. Write preset properties to camera

  txId = await writePreset(device, preset, txId);

  // 2. Initiate open capture / conversion session

  await ptpTransaction(device, 0x101C, txId++); // InitiateOpenCapture

  // 3. Send the RAF file

  const sendObjectOp = 0x100D;

  await ptpTransaction(device, sendObjectOp, txId++, [], rafData);

  // 4. Poll for completion and get JPEG back

  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; // Only used when WB = Color Temp mode (0x0012)

  dynamicRange: number;

  highlightTone: number;

  shadowTone: number;

  color: number;

  sharpness: number;

  highIsoNR: number;       // User-facing value (-4 to +4), encode before writing

  clarity: number;

}

// Export preset as shareable link

function exportPresetAsLink(preset: FilmKitPreset): string {

  const encoded = btoa(JSON.stringify(preset));

  return `https://filmkit.eggrice.soy/?preset=${encoded}`;

}

// Import preset from link/text

function importPreset(input: string): FilmKitPreset {

  // Handle URL with ?preset= param

  try {

    const url = new URL(input);

    const param = url.searchParams.get('preset');

    if (param) return JSON.parse(atob(param));

  } catch {}

  // Handle raw base64 or JSON

  try { return JSON.parse(atob(input)); } catch {}

  try { return JSON.parse(input); } catch {}

  throw new Error('Invalid preset format');

}

Capturing USB Traffic for New Camera Support

To help add support for a new Fujifilm X-series camera:

  • 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

# Check if udev rule is applied

lsusb | grep -i fuji

# Should show Fujifilm device

# Verify permissions

ls -la /dev/bus/usb/$(lsusb | grep -i fuji | awk '{print $2"/"$4}' | tr -d ':')

# Should show rw-rw-rw- or similar open permissions

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

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card