editor.js 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803
  1. /*
  2. editor.js — UI controller for the Raw Editor WebApp.
  3. Wires the Camera-Raw style controls to the WebGL develop pipeline, handles
  4. file loading (ArozOS input files, file picker, drag & drop), the live
  5. histogram, LUT loading, auto white-balance / auto tone, and saving the
  6. developed image back to the user's storage as a JPEG.
  7. */
  8. (function () {
  9. "use strict";
  10. var renderer = null;
  11. var glOK = true;
  12. var decoded = null; // last decoded {data,width,height,meta,source}
  13. var sourceFile = null; // {filename, filepath} of the opened file
  14. var lut = null; // parsed LUT
  15. var renderQueued = false;
  16. var defaults = {
  17. temperature: 5500, tint: 0, exposure: 0, contrast: 0,
  18. highlights: 0, shadows: 0, whites: 0, blacks: 0,
  19. texture: 0, clarity: 0, dehaze: 0, vibrance: 0, saturation: 0,
  20. vignette: 0, grain: 0, lutAmount: 100
  21. };
  22. var state = Object.assign({}, defaults);
  23. state.baseTemp = 5500;
  24. state.treatment = "color";
  25. state.lutEnabled = true;
  26. // Per-group bypass ("eye") toggles.
  27. state.groupOn = { light: true, color: true, effects: true, lut: true };
  28. // ---- init WebGL ------------------------------------------------------
  29. try {
  30. renderer = GLRender.create(document.getElementById("view"));
  31. } catch (e) {
  32. glOK = false;
  33. showFatal(e.message);
  34. }
  35. // =====================================================================
  36. // Slider wiring
  37. // =====================================================================
  38. function clampToRange(el, v) {
  39. var min = parseFloat(el.dataset.min), max = parseFloat(el.dataset.max);
  40. if (v < min) v = min;
  41. if (v > max) v = max;
  42. return v;
  43. }
  44. function initSliders() {
  45. document.querySelectorAll(".slider").forEach(function (row) {
  46. var key = row.dataset.key;
  47. var range = row.querySelector("input[type=range]");
  48. var num = row.querySelector(".s-val");
  49. var step = row.dataset.step || "1";
  50. range.min = row.dataset.min; range.max = row.dataset.max; range.step = step;
  51. num.min = row.dataset.min; num.max = row.dataset.max; num.step = step;
  52. var def = parseFloat(row.dataset.def);
  53. setSlider(row, def);
  54. range.addEventListener("input", function () {
  55. var v = parseFloat(range.value);
  56. num.value = fmt(v, step);
  57. state[key] = v;
  58. scheduleRender();
  59. });
  60. num.addEventListener("change", function () {
  61. var v = parseFloat(num.value);
  62. if (isNaN(v)) v = parseFloat(row.dataset.def);
  63. v = clampToRange(row, v);
  64. num.value = fmt(v, step);
  65. range.value = v;
  66. state[key] = v;
  67. scheduleRender();
  68. });
  69. // double click resets one slider to its default
  70. row.querySelector(".s-name").addEventListener("dblclick", function () {
  71. setSlider(row, parseFloat(row.dataset.def));
  72. state[key] = parseFloat(row.dataset.def);
  73. scheduleRender();
  74. });
  75. });
  76. }
  77. function fmt(v, step) {
  78. return (parseFloat(step) < 1) ? (Math.round(v * 100) / 100).toString() : Math.round(v).toString();
  79. }
  80. function setSlider(row, v) {
  81. var range = row.querySelector("input[type=range]");
  82. var num = row.querySelector(".s-val");
  83. range.value = v;
  84. num.value = fmt(v, row.dataset.step || "1");
  85. }
  86. function setSliderByKey(key, v) {
  87. var row = document.querySelector('.slider[data-key="' + key + '"]');
  88. if (row) setSlider(row, v);
  89. state[key] = v;
  90. }
  91. // =====================================================================
  92. // Render
  93. // =====================================================================
  94. function scheduleRender() {
  95. if (renderQueued || !renderer || !decoded) return;
  96. renderQueued = true;
  97. requestAnimationFrame(function () {
  98. renderQueued = false;
  99. doRender();
  100. });
  101. }
  102. function getParams() {
  103. var g = state.groupOn;
  104. var p = {
  105. baseTemp: state.baseTemp,
  106. // Color group
  107. temperature: g.color ? state.temperature : state.baseTemp,
  108. tint: g.color ? state.tint : 0,
  109. vibrance: g.color ? state.vibrance : 0,
  110. saturation: g.color ? state.saturation : 0,
  111. // Light group
  112. exposure: g.light ? state.exposure : 0,
  113. contrast: g.light ? state.contrast : 0,
  114. highlights: g.light ? state.highlights : 0,
  115. shadows: g.light ? state.shadows : 0,
  116. whites: g.light ? state.whites : 0,
  117. blacks: g.light ? state.blacks : 0,
  118. // Effects group
  119. texture: g.effects ? state.texture : 0,
  120. clarity: g.effects ? state.clarity : 0,
  121. dehaze: g.effects ? state.dehaze : 0,
  122. vignette: g.effects ? state.vignette : 0,
  123. grain: g.effects ? state.grain : 0,
  124. // LUT group
  125. lutEnabled: !!(lut && state.lutEnabled && g.lut),
  126. lutAmount: state.lutAmount / 100
  127. };
  128. if (state.treatment === "bw") { p.saturation = -100; p.vibrance = 0; }
  129. return p;
  130. }
  131. function doRender() {
  132. if (!renderer || !decoded) return;
  133. renderer.render(getParams());
  134. updateHistogram();
  135. updateFilmstrip();
  136. }
  137. // =====================================================================
  138. // Histogram
  139. // =====================================================================
  140. var histSmall = document.createElement("canvas");
  141. histSmall.width = 252; histSmall.height = 84;
  142. var histSmallCtx = histSmall.getContext("2d");
  143. function updateHistogram() {
  144. var view = document.getElementById("view");
  145. var hc = document.getElementById("histogram");
  146. var ctx = hc.getContext("2d");
  147. ctx.clearRect(0, 0, hc.width, hc.height);
  148. if (!decoded) return;
  149. try {
  150. histSmallCtx.drawImage(view, 0, 0, histSmall.width, histSmall.height);
  151. } catch (e) { return; }
  152. var img = histSmallCtx.getImageData(0, 0, histSmall.width, histSmall.height).data;
  153. var r = new Uint32Array(256), g = new Uint32Array(256), b = new Uint32Array(256);
  154. for (var i = 0; i < img.length; i += 4) {
  155. r[img[i]]++; g[img[i + 1]]++; b[img[i + 2]]++;
  156. }
  157. var max = 1;
  158. for (var k = 1; k < 255; k++) { // ignore pure black/white spikes for scaling
  159. if (r[k] > max) max = r[k];
  160. if (g[k] > max) max = g[k];
  161. if (b[k] > max) max = b[k];
  162. }
  163. drawChannel(ctx, r, max, "rgba(255,80,80,0.75)");
  164. drawChannel(ctx, g, max, "rgba(90,220,90,0.75)");
  165. drawChannel(ctx, b, max, "rgba(90,140,255,0.75)");
  166. // Clipping indicators (fraction of pixels pinned to 0 / 255).
  167. var totalPx = histSmall.width * histSmall.height;
  168. var hi = Math.max(r[255], g[255], b[255]);
  169. var lo = Math.max(r[0], g[0], b[0]);
  170. var ch = document.getElementById("clipHigh");
  171. var cs = document.getElementById("clipShadow");
  172. if (ch) ch.classList.toggle("active-high", hi / totalPx > 0.01);
  173. if (cs) cs.classList.toggle("active-shadow", lo / totalPx > 0.01);
  174. }
  175. // Small preview thumbnail in the bottom filmstrip.
  176. function updateFilmstrip() {
  177. var fs = document.getElementById("filmstrip");
  178. var v = document.getElementById("view");
  179. if (!fs || !v.width) return;
  180. var ctx = fs.getContext("2d");
  181. ctx.fillStyle = "#111"; ctx.fillRect(0, 0, fs.width, fs.height);
  182. var s = Math.min(fs.width / v.width, fs.height / v.height);
  183. var w = v.width * s, h = v.height * s;
  184. try { ctx.drawImage(v, (fs.width - w) / 2, (fs.height - h) / 2, w, h); } catch (e) { /* ignore */ }
  185. }
  186. function drawChannel(ctx, arr, max, color) {
  187. var w = ctx.canvas.width, h = ctx.canvas.height;
  188. ctx.globalCompositeOperation = "lighter";
  189. ctx.fillStyle = color;
  190. ctx.beginPath();
  191. ctx.moveTo(0, h);
  192. for (var x = 0; x < 256; x++) {
  193. var v = Math.min(1, arr[x] / max);
  194. var px = (x / 255) * w;
  195. var py = h - v * (h - 2);
  196. ctx.lineTo(px, py);
  197. }
  198. ctx.lineTo(w, h);
  199. ctx.closePath();
  200. ctx.fill();
  201. ctx.globalCompositeOperation = "source-over";
  202. }
  203. // =====================================================================
  204. // File loading
  205. // =====================================================================
  206. function showLoader(text) {
  207. document.getElementById("loaderText").textContent = text || "Working...";
  208. document.getElementById("loader").style.display = "flex";
  209. }
  210. function hideLoader() { document.getElementById("loader").style.display = "none"; }
  211. function showFatal(msg) {
  212. var dh = document.getElementById("dropHint");
  213. if (dh) dh.innerHTML = '<i class="warning circle icon huge"></i><p>' + escapeHtml(msg) + "</p>";
  214. }
  215. function escapeHtml(s) {
  216. return String(s).replace(/[&<>"]/g, function (c) {
  217. return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
  218. });
  219. }
  220. function loadFromPath(filepath, filename) {
  221. if (!glOK) return;
  222. sourceFile = { filepath: filepath, filename: filename };
  223. showLoader("Reading file...");
  224. document.getElementById("dropHint").style.display = "none";
  225. var url = "../media?file=" + encodeURIComponent(filepath);
  226. fetch(url).then(function (resp) {
  227. if (!resp.ok) throw new Error("Could not read file (HTTP " + resp.status + ")");
  228. return resp.arrayBuffer();
  229. }).then(function (buf) {
  230. showLoader("Decoding " + (filename || "image") + " ...");
  231. // Defer so the loader paints before the heavy decode.
  232. setTimeout(function () { decodeBuffer(buf, filename); }, 30);
  233. }).catch(function (err) {
  234. hideLoader();
  235. showFatal(err.message || String(err));
  236. });
  237. }
  238. function loadFromArrayBuffer(buf, filename) {
  239. showLoader("Decoding " + (filename || "image") + " ...");
  240. document.getElementById("dropHint").style.display = "none";
  241. setTimeout(function () { decodeBuffer(buf, filename); }, 30);
  242. }
  243. function decodeBuffer(buf, filename) {
  244. RawDecoder.decode(buf, filename).then(function (res) {
  245. decoded = res;
  246. renderer.setImage(res);
  247. document.getElementById("view").style.display = "block";
  248. applyMeta(res.meta, filename, res);
  249. resetAll(true);
  250. fitToWindow();
  251. hideLoader();
  252. }).catch(function (err) {
  253. hideLoader();
  254. showFatal(err.message || String(err));
  255. });
  256. }
  257. function applyMeta(meta, filename, res) {
  258. state.baseTemp = (meta && meta.temp) ? meta.temp : 5500;
  259. defaults.temperature = state.baseTemp;
  260. document.getElementById("fileTitle").textContent =
  261. (filename || "Untitled") + (meta && meta.camera ? " — " + meta.camera : "");
  262. // EXIF line (set before the title call so nothing can block it)
  263. setText("exifShutter", meta && meta.shutter ? formatShutter(meta.shutter) : "--");
  264. setText("exifAperture", meta && meta.aperture ? "f/" + round1(meta.aperture) : "--");
  265. setText("exifIso", meta && meta.iso ? "ISO " + meta.iso : "--");
  266. setText("exifFocal", meta && meta.focal ? Math.round(meta.focal) + " mm" : "--");
  267. try { ao_module_setWindowTitle("Raw Editor - " + (filename || "Untitled")); } catch (e) { /* ignore */ }
  268. // Status line
  269. var srcLabel = { "raw-demosaic": "RAW demosaiced", "embedded-preview": "Embedded preview", "image": "Image" }[res.source] || res.source;
  270. setText("statusInfo", srcLabel + " · " + res.width + " x " + res.height + " px");
  271. }
  272. function formatShutter(t) {
  273. if (t >= 1) return round1(t) + " s";
  274. return "1/" + Math.round(1 / t) + " s";
  275. }
  276. function round1(v) { return Math.round(v * 10) / 10; }
  277. function setText(id, t) { var e = document.getElementById(id); if (e) e.textContent = t; }
  278. // =====================================================================
  279. // White balance presets + auto
  280. // =====================================================================
  281. var wbPresets = { daylight: 5500, cloudy: 6500, shade: 7500, tungsten: 2850, fluorescent: 3800 };
  282. document.getElementById("wbPreset").addEventListener("change", function () {
  283. var v = this.value;
  284. if (v === "asshot") { setSliderByKey("temperature", state.baseTemp); setSliderByKey("tint", 0); }
  285. else if (v === "auto") { autoWhiteBalance(); }
  286. else if (wbPresets[v] != null) { setSliderByKey("temperature", wbPresets[v]); setSliderByKey("tint", 0); }
  287. scheduleRender();
  288. });
  289. // When temp/tint are edited manually flip the preset to Custom.
  290. ["temperature", "tint"].forEach(function (key) {
  291. var row = document.querySelector('.slider[data-key="' + key + '"]');
  292. row.querySelector("input[type=range]").addEventListener("input", function () {
  293. document.getElementById("wbPreset").value = "custom";
  294. });
  295. });
  296. function autoWhiteBalance() {
  297. if (!decoded) return;
  298. var d = decoded.data, n = d.length;
  299. var sr = 0, sg = 0, sb = 0, cnt = 0;
  300. var stride = Math.max(4, Math.floor(n / 4 / 40000) * 4);
  301. for (var i = 0; i < n; i += stride) { sr += d[i]; sg += d[i + 1]; sb += d[i + 2]; cnt++; }
  302. var ar = sr / cnt, ag = sg / cnt, ab = sb / cnt;
  303. if (ar <= 0 || ab <= 0) return;
  304. // Solve kelvinGain to equalise R and B: (1+0.9wr)/(1-0.9wr) = ab/ar.
  305. var ratio = ab / ar;
  306. var wr = (ratio - 1) / (0.9 * (ratio + 1));
  307. wr = Math.max(-1, Math.min(1, wr));
  308. var temp = state.baseTemp * Math.exp(wr * (Math.log(50000) - Math.log(2000)));
  309. temp = Math.max(2000, Math.min(50000, temp));
  310. // Tint: green vs magenta balance.
  311. var tint = ((ar + ab) / 2 - ag) / ((ar + ab) / 2 + ag) * 150;
  312. tint = Math.max(-150, Math.min(150, tint));
  313. setSliderByKey("temperature", Math.round(temp));
  314. setSliderByKey("tint", Math.round(tint));
  315. }
  316. // =====================================================================
  317. // Auto tone / reset
  318. // =====================================================================
  319. document.getElementById("btnAuto").addEventListener("click", function () { autoTone(); scheduleRender(); });
  320. document.getElementById("btnDefault").addEventListener("click", function () {
  321. ["exposure", "contrast", "highlights", "shadows", "whites", "blacks"].forEach(function (k) {
  322. setSliderByKey(k, defaults[k]);
  323. });
  324. scheduleRender();
  325. });
  326. function autoTone() {
  327. if (!decoded) return;
  328. var d = decoded.data, n = d.length;
  329. var sum = 0, cnt = 0, hiClip = 0, loClip = 0;
  330. var stride = Math.max(4, Math.floor(n / 4 / 40000) * 4);
  331. for (var i = 0; i < n; i += stride) {
  332. var lum = Math.pow(Math.max(0, 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]), 1 / 2.2);
  333. sum += lum; cnt++;
  334. if (lum > 0.96) hiClip++;
  335. if (lum < 0.03) loClip++;
  336. }
  337. var mean = sum / cnt;
  338. var exposure = Math.log(0.46 / Math.max(0.03, mean)) / Math.log(2);
  339. exposure = Math.max(-2.5, Math.min(2.5, exposure));
  340. setSliderByKey("exposure", Math.round(exposure * 100) / 100);
  341. setSliderByKey("contrast", 8);
  342. setSliderByKey("highlights", hiClip / cnt > 0.02 ? -35 : -10);
  343. setSliderByKey("shadows", loClip / cnt > 0.02 ? 35 : 12);
  344. setSliderByKey("whites", 8);
  345. setSliderByKey("blacks", -6);
  346. }
  347. function resetAll(keepImage) {
  348. Object.keys(defaults).forEach(function (k) {
  349. var def = (k === "temperature") ? state.baseTemp : defaults[k];
  350. setSliderByKey(k, def);
  351. });
  352. state.treatment = "color";
  353. document.getElementById("btnBW").classList.remove("active");
  354. document.getElementById("wbPreset").value = "asshot";
  355. if (keepImage) scheduleRender();
  356. }
  357. document.getElementById("btnReset").addEventListener("click", function () { resetAll(true); });
  358. // Treatment (B&W) toggle in the Edit header
  359. document.getElementById("btnBW").addEventListener("click", function () {
  360. state.treatment = (state.treatment === "bw") ? "color" : "bw";
  361. this.classList.toggle("active", state.treatment === "bw");
  362. scheduleRender();
  363. });
  364. // Auto white balance eyedropper
  365. document.getElementById("btnEyedrop").addEventListener("click", function () {
  366. autoWhiteBalance();
  367. document.getElementById("wbPreset").value = "auto";
  368. scheduleRender();
  369. });
  370. // =====================================================================
  371. // Collapsible groups (chevron) + per-group bypass (eye)
  372. // =====================================================================
  373. document.querySelectorAll(".group-head").forEach(function (head) {
  374. var group = head.parentElement;
  375. head.addEventListener("click", function (e) {
  376. if (e.target.classList.contains("eye-toggle")) return;
  377. group.classList.toggle("collapsed");
  378. });
  379. var eye = head.querySelector(".eye-toggle");
  380. if (eye) {
  381. eye.addEventListener("click", function (e) {
  382. e.stopPropagation();
  383. var key = group.dataset.group;
  384. state.groupOn[key] = !state.groupOn[key];
  385. group.classList.toggle("bypassed", !state.groupOn[key]);
  386. eye.className = state.groupOn[key] ? "eye icon eye-toggle" : "eye slash icon eye-toggle";
  387. scheduleRender();
  388. });
  389. }
  390. });
  391. // =====================================================================
  392. // LUT (library folder in the user's ArozOS storage + local import)
  393. // =====================================================================
  394. var LUT_ROOT = "user:/RawEditor"; // parent folder
  395. var LUT_DIR = "user:/RawEditor/LUTs"; // where .cube files live
  396. function inDesktop() {
  397. return (typeof ao_module_virtualDesktop !== "undefined" && ao_module_virtualDesktop) || window.parent !== window;
  398. }
  399. // --- minimal ArozOS file-system helpers ---
  400. function fsPost(url, body) {
  401. return fetch(url, {
  402. method: "POST",
  403. headers: { "Content-Type": "application/x-www-form-urlencoded" },
  404. body: body
  405. });
  406. }
  407. function fsListDir(dir) {
  408. return fsPost("../system/file_system/listDir", "dir=" + encodeURIComponent(dir)).then(function (r) { return r.json(); });
  409. }
  410. function fsCSRF() {
  411. return fetch("../system/csrf/new").then(function (r) { return r.text(); });
  412. }
  413. function fsNewFolder(src, name) {
  414. return fsCSRF().then(function (token) {
  415. return fsPost("../system/file_system/newItem",
  416. "type=folder&src=" + encodeURIComponent(src) + "&filename=" + encodeURIComponent(name) + "&csrft=" + encodeURIComponent(token));
  417. });
  418. }
  419. function applyLut(parsed, displayName) {
  420. lut = parsed;
  421. renderer.setLUT(lut);
  422. state.lutEnabled = true;
  423. var g = document.querySelector('.group[data-group="lut"]');
  424. if (g) { g.classList.remove("bypassed"); }
  425. state.groupOn.lut = true;
  426. document.getElementById("lutEnabled").checked = true;
  427. document.getElementById("lutInfo").style.display = "block";
  428. document.getElementById("lutName").textContent = (parsed.title ? parsed.title + " " : "") + displayName + " (" + parsed.size + "³)";
  429. scheduleRender();
  430. }
  431. // Load a LUT stored in the user's storage by virtual path.
  432. function loadLutFromPath(filepath, name) {
  433. fetch("../media?file=" + encodeURIComponent(filepath)).then(function (r) {
  434. if (!r.ok) throw new Error("Could not read file (HTTP " + r.status + ")");
  435. return r.text();
  436. }).then(function (text) {
  437. applyLut(LUTParser.parse(text), name);
  438. }).catch(function (e) { alert("Could not load LUT: " + e.message); });
  439. }
  440. // Populate the library dropdown from a listDir result.
  441. function populateLutLibrary(list) {
  442. var sel = document.getElementById("lutLibrary");
  443. var hint = document.getElementById("lutEmpty");
  444. var current = sel.value;
  445. sel.innerHTML = '<option value="">— Select a LUT —</option>';
  446. var cubes = (list || []).filter(function (f) { return !f.IsDir && /\.cube$/i.test(f.Filename); });
  447. cubes.sort(function (a, b) { return a.Filename.toLowerCase() < b.Filename.toLowerCase() ? -1 : 1; });
  448. cubes.forEach(function (f) {
  449. var o = document.createElement("option");
  450. o.value = f.Filepath;
  451. o.textContent = f.Filename.replace(/\.cube$/i, "");
  452. sel.appendChild(o);
  453. });
  454. if (current) sel.value = current;
  455. if (hint) {
  456. hint.textContent = cubes.length
  457. ? (cubes.length + " LUT" + (cubes.length > 1 ? "s" : "") + " in library")
  458. : "No LUTs yet — add .cube files to user:/RawEditor/LUTs or import one below.";
  459. }
  460. }
  461. // List the LUT folder, creating it (and its parent) on first use.
  462. function refreshLutLibrary() {
  463. if (!inDesktop()) {
  464. var hint = document.getElementById("lutEmpty");
  465. if (hint) hint.textContent = "Library needs the ArozOS desktop — use Import below.";
  466. return;
  467. }
  468. fsListDir(LUT_DIR).then(function (list) {
  469. if (list && list.error) {
  470. // Folder missing — create parent then LUT folder, then retry.
  471. return fsNewFolder("user:/", "RawEditor").catch(function () { }).then(function () {
  472. return fsNewFolder(LUT_ROOT + "/", "LUTs").catch(function () { });
  473. }).then(function () { return fsListDir(LUT_DIR); });
  474. }
  475. return list;
  476. }).then(function (list) {
  477. populateLutLibrary(Array.isArray(list) ? list : []);
  478. }).catch(function () {
  479. var hint = document.getElementById("lutEmpty");
  480. if (hint) hint.textContent = "Could not read the LUT folder.";
  481. });
  482. }
  483. document.getElementById("lutLibrary").addEventListener("change", function () {
  484. if (!this.value) return;
  485. loadLutFromPath(this.value, this.options[this.selectedIndex].text);
  486. });
  487. document.getElementById("btnLutRefresh").addEventListener("click", refreshLutLibrary);
  488. // Import: load a local .cube immediately and (in desktop) save it to the library.
  489. document.getElementById("btnLoadLut").addEventListener("click", function () {
  490. document.getElementById("lutFile").click();
  491. });
  492. document.getElementById("lutFile").addEventListener("change", function () {
  493. var f = this.files[0];
  494. this.value = "";
  495. if (!f) return;
  496. var reader = new FileReader();
  497. reader.onload = function () {
  498. var parsed;
  499. try { parsed = LUTParser.parse(reader.result); }
  500. catch (e) { alert("Could not load LUT: " + e.message); return; }
  501. applyLut(parsed, f.name.replace(/\.cube$/i, ""));
  502. // Persist into the library so it shows up next time.
  503. if (inDesktop() && typeof ao_module_uploadFile === "function") {
  504. fsNewFolder("user:/", "RawEditor").catch(function () { }).then(function () {
  505. return fsNewFolder(LUT_ROOT + "/", "LUTs").catch(function () { });
  506. }).then(function () {
  507. ao_module_uploadFile(f, LUT_DIR, function () { refreshLutLibrary(); });
  508. });
  509. }
  510. };
  511. reader.readAsText(f);
  512. });
  513. document.getElementById("lutEnabled").addEventListener("change", function () {
  514. state.lutEnabled = this.checked;
  515. scheduleRender();
  516. });
  517. document.getElementById("btnClearLut").addEventListener("click", function () {
  518. lut = null;
  519. renderer.setLUT(null);
  520. document.getElementById("lutInfo").style.display = "none";
  521. document.getElementById("lutLibrary").value = "";
  522. scheduleRender();
  523. });
  524. // =====================================================================
  525. // Open / drag & drop
  526. // =====================================================================
  527. function openPicker() {
  528. if (typeof ao_module_openFileSelector === "function" && window.parent !== window) {
  529. ao_module_openFileSelector(function (files) {
  530. if (files && files.length) loadFromPath(files[0].filepath, files[0].filename);
  531. }, "user:/", "file", false);
  532. } else {
  533. var inp = document.createElement("input");
  534. inp.type = "file";
  535. inp.accept = ".arw,.dng,.nef,.cr2,.cr3,.orf,.raf,.rw2,.pef,.srw,.tif,.tiff,.jpg,.jpeg,.png,.webp";
  536. inp.onchange = function () {
  537. var f = inp.files[0];
  538. if (!f) return;
  539. sourceFile = null;
  540. f.arrayBuffer().then(function (buf) { loadFromArrayBuffer(buf, f.name); });
  541. };
  542. inp.click();
  543. }
  544. }
  545. document.getElementById("btnOpen").addEventListener("click", openPicker);
  546. document.getElementById("btnOpen2").addEventListener("click", openPicker);
  547. var stage = document.getElementById("stage");
  548. stage.addEventListener("dragover", function (e) { e.preventDefault(); stage.classList.add("dragover"); });
  549. stage.addEventListener("dragleave", function () { stage.classList.remove("dragover"); });
  550. stage.addEventListener("drop", function (e) {
  551. e.preventDefault();
  552. stage.classList.remove("dragover");
  553. // Local OS file drop.
  554. if (e.dataTransfer.files && e.dataTransfer.files.length) {
  555. var f = e.dataTransfer.files[0];
  556. sourceFile = null;
  557. f.arrayBuffer().then(function (buf) { loadFromArrayBuffer(buf, f.name); });
  558. return;
  559. }
  560. // ArozOS file-explorer drop.
  561. try {
  562. var info = ao_module_utils.getDropFileInfo(e);
  563. if (info && info.length) loadFromPath(info[0].filepath, info[0].filename);
  564. } catch (err) { /* ignore */ }
  565. });
  566. // =====================================================================
  567. // Zoom & pan (scroll to zoom at cursor, right/middle-drag to pan)
  568. // =====================================================================
  569. var vz = { zoom: 1, fit: 1, panX: 0, panY: 0 };
  570. function applyTransform() {
  571. var v = document.getElementById("view");
  572. v.style.transform = "translate(-50%,-50%) translate(" + vz.panX + "px," + vz.panY + "px) scale(" + vz.zoom + ")";
  573. }
  574. function updateZoomLabel() {
  575. var el = document.getElementById("zoomReadout");
  576. if (el) el.textContent = Math.round(vz.zoom * 100) + "%";
  577. }
  578. function computeFit() {
  579. var v = document.getElementById("view");
  580. if (!v.width) return 1;
  581. var pad = 32;
  582. return Math.min((stage.clientWidth - pad) / v.width, (stage.clientHeight - pad) / v.height);
  583. }
  584. function fitToWindow() {
  585. vz.fit = computeFit();
  586. vz.zoom = vz.fit; vz.panX = 0; vz.panY = 0;
  587. applyTransform(); updateZoomLabel();
  588. }
  589. function zoomActual() {
  590. vz.zoom = 1; vz.panX = 0; vz.panY = 0;
  591. applyTransform(); updateZoomLabel();
  592. }
  593. function setZoomAt(z2, cx, cy) {
  594. z2 = Math.max(vz.fit * 0.5, Math.min(16, z2));
  595. // Keep the image point under the cursor fixed while zooming.
  596. vz.panX = cx - (z2 / vz.zoom) * (cx - vz.panX);
  597. vz.panY = cy - (z2 / vz.zoom) * (cy - vz.panY);
  598. vz.zoom = z2;
  599. applyTransform(); updateZoomLabel();
  600. }
  601. document.getElementById("btnFit").addEventListener("click", fitToWindow);
  602. document.getElementById("btnZoomFit").addEventListener("click", fitToWindow);
  603. document.getElementById("btnZoom100").addEventListener("click", zoomActual);
  604. // Scroll to zoom, centred on the cursor.
  605. stage.addEventListener("wheel", function (e) {
  606. if (!decoded) return;
  607. e.preventDefault();
  608. var rect = stage.getBoundingClientRect();
  609. var cx = e.clientX - (rect.left + rect.width / 2);
  610. var cy = e.clientY - (rect.top + rect.height / 2);
  611. var factor = Math.pow(1.0016, -e.deltaY);
  612. setZoomAt(vz.zoom * factor, cx, cy);
  613. }, { passive: false });
  614. // Left (or middle) mouse button drag to pan.
  615. var panning = false, lastX = 0, lastY = 0;
  616. stage.addEventListener("mousedown", function (e) {
  617. if (!decoded || (e.button !== 0 && e.button !== 1)) return;
  618. panning = true; lastX = e.clientX; lastY = e.clientY;
  619. stage.classList.add("panning");
  620. e.preventDefault();
  621. });
  622. window.addEventListener("mousemove", function (e) {
  623. if (!panning) return;
  624. vz.panX += e.clientX - lastX; vz.panY += e.clientY - lastY;
  625. lastX = e.clientX; lastY = e.clientY;
  626. applyTransform();
  627. });
  628. window.addEventListener("mouseup", function () {
  629. if (panning) { panning = false; stage.classList.remove("panning"); }
  630. });
  631. // Re-fit on window resize while the view is at fit zoom.
  632. window.addEventListener("resize", function () {
  633. if (!decoded) return;
  634. if (Math.abs(vz.zoom - vz.fit) < 0.001 && vz.panX === 0 && vz.panY === 0) fitToWindow();
  635. else vz.fit = computeFit();
  636. });
  637. // =====================================================================
  638. // Save + Done
  639. // =====================================================================
  640. document.getElementById("btnSave").addEventListener("click", saveImage);
  641. document.getElementById("btnDone").addEventListener("click", openInPixelStudio);
  642. document.getElementById("btnCancel").addEventListener("click", function () {
  643. if (typeof ao_module_close === "function") ao_module_close();
  644. });
  645. // Hand the developed image off to Pixel Studio: write the current develop to
  646. // a temporary file (tmp:/ is cleared automatically) then launch Pixel Studio
  647. // as a float window with that file as its input.
  648. function openInPixelStudio() {
  649. var inDesktop = (typeof ao_module_virtualDesktop !== "undefined" && ao_module_virtualDesktop);
  650. if (!decoded || !renderer) {
  651. if (typeof ao_module_close === "function") ao_module_close();
  652. return;
  653. }
  654. if (!inDesktop || typeof ao_module_uploadFile !== "function") {
  655. // Outside the ArozOS desktop we cannot open another module — just save.
  656. saveImage();
  657. return;
  658. }
  659. doRender(); // ensure the canvas holds the latest develop
  660. var view = document.getElementById("view");
  661. view.toBlob(function (blob) {
  662. if (!blob) { alert("Failed to encode image."); return; }
  663. var base = (sourceFile && sourceFile.filename) ? stripExt(sourceFile.filename) : "Untitled";
  664. var fname = base + "_raw_" + Date.now() + ".jpg";
  665. var tmpDir = "tmp:/RawEditor";
  666. var file = ao_module_utils.blobToFile(blob, fname);
  667. showLoader("Opening in Pixel Studio...");
  668. ao_module_uploadFile(file, tmpDir, function () {
  669. hideLoader();
  670. launchPixelStudio(tmpDir + "/" + fname, fname);
  671. if (typeof ao_module_close === "function") ao_module_close();
  672. }, undefined, function () {
  673. hideLoader();
  674. alert("Could not hand the image to Pixel Studio. Try 'Save Image...' instead.");
  675. });
  676. }, "image/jpeg", 0.95);
  677. }
  678. function launchPixelStudio(filepath, filename) {
  679. var hash = encodeURIComponent(JSON.stringify([{ filename: filename, filepath: filepath }]));
  680. ao_module_newfw({
  681. url: "Pixel Studio/index.html#" + hash,
  682. width: 1280,
  683. height: 820,
  684. appicon: "Pixel Studio/img/module_icon.png",
  685. title: "Pixel Studio - " + filename
  686. });
  687. }
  688. function saveImage() {
  689. if (!decoded || !renderer) { alert("Nothing to save yet."); return; }
  690. doRender(); // make sure the canvas holds the latest develop
  691. var view = document.getElementById("view");
  692. view.toBlob(function (blob) {
  693. if (!blob) { alert("Failed to encode image."); return; }
  694. var baseName = (sourceFile && sourceFile.filename ? stripExt(sourceFile.filename) : "Untitled") + "_edited.jpg";
  695. if (window.parent !== window && typeof ao_module_openFileSelector === "function") {
  696. var defDir = "user:/Desktop";
  697. if (sourceFile && sourceFile.filepath) {
  698. var parts = sourceFile.filepath.split("/"); parts.pop(); defDir = parts.join("/");
  699. }
  700. ao_module_openFileSelector(function (files) {
  701. if (!files || !files.length) return;
  702. var fp = files[0].filepath.split("/"); var fn = fp.pop(); var dir = fp.join("/");
  703. if (!/\.jpe?g$/i.test(fn)) fn = stripExt(fn) + ".jpg";
  704. uploadBlob(blob, fn, dir);
  705. }, defDir, "new", false, { defaultName: baseName });
  706. } else {
  707. // Fallback: browser download.
  708. var a = document.createElement("a");
  709. a.href = URL.createObjectURL(blob);
  710. a.download = baseName;
  711. a.click();
  712. setTimeout(function () { URL.revokeObjectURL(a.href); }, 4000);
  713. }
  714. }, "image/jpeg", 0.92);
  715. }
  716. function uploadBlob(blob, filename, dir) {
  717. var file = ao_module_utils.blobToFile(blob, filename);
  718. showLoader("Saving " + filename + " ...");
  719. ao_module_uploadFile(file, dir, function () {
  720. hideLoader();
  721. setText("statusInfo", "Saved: " + dir + "/" + filename);
  722. }, undefined, function () {
  723. hideLoader();
  724. alert("Failed to save image to " + dir);
  725. });
  726. }
  727. function stripExt(name) { var i = name.lastIndexOf("."); return i < 0 ? name : name.substring(0, i); }
  728. // =====================================================================
  729. // Boot
  730. // =====================================================================
  731. initSliders();
  732. refreshLutLibrary();
  733. if (glOK) {
  734. var inputFiles = (typeof ao_module_loadInputFiles === "function") ? ao_module_loadInputFiles() : null;
  735. if (inputFiles && inputFiles.length) {
  736. loadFromPath(inputFiles[0].filepath, inputFiles[0].filename);
  737. }
  738. }
  739. })();