M6: animated GIF fills and TIFF images (#116)
Animated GIF image fills keep their frames (first frame on the canvas and in exports; the presenter and preview play them, capped frames and memory, idle when not playing). TIFF images open, import, place and drop (pure-Rust decoder already in the tree); SVG exports embed TIFF and BMP as PNG. HEIC and video stay out (non-Rust decoders).
This commit is contained in:
parent
ecb3656c69
commit
5b321a779c
|
|
@ -1502,6 +1502,7 @@ dependencies = [
|
|||
"figcraft-render",
|
||||
"figcraft-svg",
|
||||
"figcraft-text",
|
||||
"image",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ postcard = { version = "1", default-features = false, features = ["alloc"] }
|
|||
|
||||
# rendering / formats
|
||||
tiny-skia = "0.12"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
usvg = "0.48"
|
||||
krilla = "0.8"
|
||||
skrifa = "0.47"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
|
|
@ -127,6 +127,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-11** · Animated GIF fills (first frame on the canvas and in exports, played in the
|
||||
presenter and the preview, waking the window only for the next frame) and TIFF images (gap 12).
|
||||
- **2026-10-11** · The last three menu items got commands: File ▸ Open recent (kept in the
|
||||
preferences, missing files greyed, Clear recent), outlines for the selection and mask outlines
|
||||
(gap 21). Menu breadth 203/203.
|
||||
|
|
@ -164,6 +166,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h |
|
||||
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
|
||||
|
|
|
|||
|
|
@ -30,3 +30,4 @@ web-time = "1.1.0"
|
|||
|
||||
[dev-dependencies]
|
||||
wat = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -418,7 +418,7 @@ fn load_with(
|
|||
.notes;
|
||||
Ok((figcraft_format::Loaded { issues: notes, ..doc.into() }, false))
|
||||
}
|
||||
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => {
|
||||
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" => {
|
||||
let mut doc = Document::new(file_stem(name_hint));
|
||||
let page = doc.first_page().ok_or(EngineError::NoDocument)?;
|
||||
let (hash, w, h) = figcraft_format::add_image(&mut doc, bytes.to_vec()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
|
|
|
|||
|
|
@ -1265,6 +1265,8 @@ mod text_fill_tests;
|
|||
#[cfg(test)]
|
||||
mod text_style_picker_tests;
|
||||
#[cfg(test)]
|
||||
mod tiff_tests;
|
||||
#[cfg(test)]
|
||||
mod variable_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_editor_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
//! TIFF images open, import and place like the other formats; damaged ones are errors.
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn tiff(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(w, h, image::Rgba([10, 120, 200, 255]));
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, image::ImageFormat::Tiff).unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiff_images_import_and_open() {
|
||||
let bytes = tiff(30, 20);
|
||||
let mut s = Session::new();
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
let out = s.execute("file.import", &json!({"data": b64, "name": "scan.tif", "x": 0, "y": 0})).unwrap();
|
||||
let id = crate::params::id_of(&out["id"]).unwrap();
|
||||
let n = s.doc().unwrap().node(id).unwrap();
|
||||
assert_eq!((n.width, n.height), (30.0, 20.0));
|
||||
// SVG viewers don't show TIFF: the export embeds it as PNG.
|
||||
let svg = String::from_utf8(crate::cmd::file::export_bytes(s.doc().unwrap(), &[id], figcraft_doc::ExportFormat::Svg, 1.0).unwrap()).unwrap();
|
||||
assert!(svg.contains("data:image/png;base64,"), "{svg}");
|
||||
let path = std::env::temp_dir().join(format!("figcraft-tiff-{}.tiff", std::process::id()));
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().images.len(), 1);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_tiffs_are_errors() {
|
||||
let bytes = tiff(8, 8);
|
||||
let mut s = Session::new();
|
||||
for junk in [&bytes[..12], &bytes[..bytes.len() / 2], b"II*\0\x08\0\0\0\xff\xff".as_slice()] {
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(junk);
|
||||
// Either refused or placed; never a panic, and the renderer copes with what was placed.
|
||||
if let Ok(out) = s.execute("file.import", &json!({"data": b64, "name": "bad.tif"})) {
|
||||
let id = crate::params::id_of(&out["id"]).unwrap();
|
||||
let _ = figcraft_render::render_node(s.doc().unwrap(), id, 1.0);
|
||||
}
|
||||
}
|
||||
let header_only = base64::engine::general_purpose::STANDARD.encode(b"II*\0\x08\0\0\0");
|
||||
assert!(s.execute("file.import", &json!({"data": header_only, "name": "bad.tif"})).is_err());
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ use tiny_skia::{FillRule, IntRect, Mask, MaskType, Pixmap, PixmapPaint, Transfor
|
|||
|
||||
use crate::backdrop::{self, Under};
|
||||
use crate::boolean::{PathCache, locked};
|
||||
use crate::paint::{prepare, ts_blend};
|
||||
use crate::paint::{prepare_at, ts_blend};
|
||||
use crate::{blur, grain, lod, shadow};
|
||||
|
||||
/// Shared state for one render.
|
||||
|
|
@ -29,6 +29,8 @@ pub struct Ctx<'a> {
|
|||
pub lod: bool,
|
||||
/// The surfaces under the one being drawn into, when it is an isolated layer (for backdrops).
|
||||
pub under: Option<&'a Under<'a>>,
|
||||
/// Seconds into animated GIFs (0: their first frame; only the presenter plays them).
|
||||
pub time: f64,
|
||||
}
|
||||
|
||||
pub fn ts_transform(a: Affine) -> Transform {
|
||||
|
|
@ -52,19 +54,9 @@ pub fn ts_path(p: &BezPath) -> Option<tiny_skia::Path> {
|
|||
b.finish()
|
||||
}
|
||||
|
||||
fn fill_with(
|
||||
pix: &mut Pixmap,
|
||||
path: &tiny_skia::Path,
|
||||
paints: &[Paint],
|
||||
w: f64,
|
||||
h: f64,
|
||||
xf: Affine,
|
||||
rule: FillRule,
|
||||
doc: &Document,
|
||||
mask: Option<&Mask>,
|
||||
) {
|
||||
fn fill_with(pix: &mut Pixmap, path: &tiny_skia::Path, paints: &[Paint], w: f64, h: f64, xf: Affine, rule: FillRule, ctx: &Ctx, mask: Option<&Mask>) {
|
||||
for p in paints {
|
||||
let Some(prep) = prepare(p, w, h, doc) else { continue };
|
||||
let Some(prep) = prepare_at(p, w, h, ctx.doc, ctx.time) else { continue };
|
||||
let paint = tiny_skia::Paint { shader: prep.shader(), anti_alias: true, blend_mode: ts_blend(p.blend), ..Default::default() };
|
||||
pix.fill_path(path, &paint, rule, ts_transform(xf), mask);
|
||||
}
|
||||
|
|
@ -491,7 +483,7 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize,
|
|||
for k in &keys {
|
||||
let Some(tp) = by_style.get(k).and_then(ts_path) else { continue };
|
||||
let fills = layout.styles.get(*k).and_then(|s| s.fills.as_ref()).unwrap_or(&n.fills);
|
||||
fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, doc, None);
|
||||
fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, ctx, None);
|
||||
}
|
||||
if let Some(f) = over_fills {
|
||||
f(pix);
|
||||
|
|
@ -501,7 +493,7 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize,
|
|||
for path in keys.iter().filter_map(|k| by_style.get(k)) {
|
||||
let s = figcraft_pathops::stroke_center(path, props.weight, &props);
|
||||
if let Some(ts) = ts_path(&s) {
|
||||
fill_with(pix, &ts, &n.strokes, w, h, gxf, FillRule::Winding, doc, None);
|
||||
fill_with(pix, &ts, &n.strokes, w, h, gxf, FillRule::Winding, ctx, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -518,7 +510,7 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize,
|
|||
&& closed
|
||||
&& let Some(tp) = ts_path(path)
|
||||
{
|
||||
fill_with(pix, &tp, &n.fills, w, h, xf, *rule, doc, None);
|
||||
fill_with(pix, &tp, &n.fills, w, h, xf, *rule, ctx, None);
|
||||
}
|
||||
if let Some(f) = over_fills {
|
||||
f(pix);
|
||||
|
|
@ -538,7 +530,7 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize,
|
|||
if let Some(g) = geom {
|
||||
let area = stroke_area(n, &g, closed);
|
||||
if let Some(tp) = ts_path(&area) {
|
||||
fill_with(pix, &tp, &n.strokes, w, h, xf, stroke_rule(n, closed), doc, None);
|
||||
fill_with(pix, &tp, &n.strokes, w, h, xf, stroke_rule(n, closed), ctx, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
//! Animated GIFs: frames and their timing, the caps on decoding, junk input, and the first frame
|
||||
//! everywhere but the presenter.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_color::{ColorProfile, ImageFilters, ImageScaleMode, Paint};
|
||||
use figcraft_doc::{Document, ImageData, NodeKind};
|
||||
use image::codecs::gif::{GifEncoder, Repeat};
|
||||
use image::{Delay, Frame, RgbaImage};
|
||||
|
||||
use crate::images::{self, MAX_FRAMES};
|
||||
|
||||
const RED: [u8; 4] = [255, 0, 0, 255];
|
||||
const GREEN: [u8; 4] = [0, 255, 0, 255];
|
||||
const BLUE: [u8; 4] = [0, 0, 255, 255];
|
||||
|
||||
/// A synthetic animated GIF: one solid `w`×`h` frame per (colour, delay in ms).
|
||||
fn gif(frames: &[([u8; 4], u32)], w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut enc = GifEncoder::new(&mut out);
|
||||
enc.set_repeat(Repeat::Infinite).unwrap();
|
||||
let frames =
|
||||
frames.iter().map(|(c, ms)| Frame::from_parts(RgbaImage::from_pixel(w, h, image::Rgba(*c)), 0, 0, Delay::from_numer_denom_ms(*ms, 1)));
|
||||
enc.encode_frames(frames).unwrap();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn data(bytes: Vec<u8>) -> ImageData {
|
||||
ImageData { bytes: Arc::new(bytes), width: 4, height: 4 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_keep_their_delays_and_loop() {
|
||||
let bytes = gif(&[(RED, 100), (GREEN, 200), (BLUE, 0)], 4, 4);
|
||||
let frames = images::decode_frames(&bytes).unwrap();
|
||||
let secs: Vec<f64> = frames.iter().map(|f| f.1).collect();
|
||||
assert_eq!(secs, vec![0.1, 0.2, 0.1], "a 0 ms delay plays as 100 ms");
|
||||
let a = images::animation("gif-timing", Some(&data(bytes)), &ImageFilters::default(), ColorProfile::Srgb).unwrap();
|
||||
assert_eq!(a.len(), 3);
|
||||
let at = |t: f64| {
|
||||
let (i, next) = a.at(t);
|
||||
(i, (next * 1000.0).round())
|
||||
};
|
||||
assert_eq!(at(0.0), (0, 100.0));
|
||||
assert_eq!(at(0.15), (1, 150.0));
|
||||
assert_eq!(at(0.35), (2, 50.0));
|
||||
assert_eq!(at(0.45), (0, 50.0), "loops after 400 ms");
|
||||
assert_eq!(at(f64::NAN), (0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stills_and_junk_never_animate() {
|
||||
let still = gif(&[(RED, 100)], 4, 4);
|
||||
assert!(images::decode_frames(&still).is_none(), "one frame is a still");
|
||||
let png = crate::encode_png(&tiny_skia::Pixmap::new(2, 2).unwrap()).unwrap();
|
||||
assert!(images::decode_frames(&png).is_none());
|
||||
let moving = gif(&[(RED, 100), (GREEN, 100)], 8, 8);
|
||||
for n in 0..moving.len() {
|
||||
// Every truncation decodes to something or nothing, never a panic.
|
||||
let _ = images::decode_frames(&moving[..n]);
|
||||
let _ = images::decode(&moving[..n]);
|
||||
}
|
||||
let mut junk = b"GIF89a".to_vec();
|
||||
junk.extend((0..200u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8));
|
||||
assert!(images::decode_frames(&junk).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoding_is_capped() {
|
||||
let many: Vec<([u8; 4], u32)> = (0..MAX_FRAMES + 5).map(|i| (if i % 2 == 0 { RED } else { GREEN }, 100)).collect();
|
||||
assert_eq!(images::decode_frames(&gif(&many, 1, 1)).unwrap().len(), MAX_FRAMES);
|
||||
// A canvas so big that one frame is past the byte cap doesn't play (its first frame is still
|
||||
// the image, where the renderer can decode it).
|
||||
let mut huge = gif(&[(RED, 100), (GREEN, 100)], 1, 1);
|
||||
huge[6..10].copy_from_slice(&[0x28, 0x23, 0x28, 0x23]);
|
||||
assert!(images::decode_frames(&huge).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_frame_draws_unless_time_moves_on() {
|
||||
let mut d = Document::new("t");
|
||||
let page = d.add_page("Page 1");
|
||||
let id = d.create(page, NodeKind::Rectangle, "GIF", 0.0, 0.0, 4.0, 4.0).unwrap();
|
||||
let bytes = gif(&[(RED, 100), (GREEN, 100)], 4, 4);
|
||||
let hash = "gif-first-frame".to_string();
|
||||
d.images.insert(hash.clone(), data(bytes));
|
||||
d.get_mut(id).unwrap().fills = vec![Paint::image(hash, ImageScaleMode::Fill)];
|
||||
let centre = |p: tiny_skia::Pixmap| {
|
||||
let c = p.pixel(2, 2).unwrap();
|
||||
[c.red(), c.green(), c.blue()]
|
||||
};
|
||||
assert_eq!(centre(crate::render_node(&d, id, 1.0).unwrap()), [255, 0, 0], "exports and the canvas: the first frame");
|
||||
assert_eq!(centre(crate::render_node_at(&d, id, 1.0, 0.15).unwrap()), [0, 255, 0], "the presenter, 150 ms in");
|
||||
let (frame, next) = images::animation_clock(&d, page, 0.15).unwrap();
|
||||
assert!(frame != images::animation_clock(&d, page, 0.05).unwrap().0 && (next - 0.05).abs() < 1e-9);
|
||||
d.get_mut(id).unwrap().visible = false;
|
||||
assert!(images::animation_clock(&d, page, 0.15).is_none(), "hidden layers don't keep the presenter awake");
|
||||
}
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
//! Decoded image cache (content hash + filters + colour profile → premultiplied pixmap). Images
|
||||
//! are taken to be sRGB; a Display P3 document gets them converted into its space.
|
||||
//!
|
||||
//! Animated GIFs keep their original bytes like any image; their first frame is the image
|
||||
//! everywhere (canvas, exports). The presenter plays them: [`frame_at`] decodes every frame on
|
||||
//! first use (capped by [`MAX_FRAMES`] and [`MAX_ANIMATION_BYTES`]) and picks the one due.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use figcraft_color::{ColorProfile, ImageFilters};
|
||||
use figcraft_doc::ImageData;
|
||||
use figcraft_color::{ColorProfile, ImageFilters, PaintKind};
|
||||
use figcraft_doc::{Document, ImageData, NodeId};
|
||||
use tiny_skia::Pixmap;
|
||||
|
||||
type Key = (String, [i32; 7], ColorProfile);
|
||||
|
|
@ -22,8 +26,11 @@ fn fkey(f: &ImageFilters) -> [i32; 7] {
|
|||
|
||||
/// Decode encoded image bytes into a premultiplied pixmap (max 16k per side).
|
||||
pub fn decode(bytes: &[u8]) -> Option<Pixmap> {
|
||||
let img = image::load_from_memory(bytes).ok()?;
|
||||
let rgba = img.to_rgba8();
|
||||
premultiplied(&image::load_from_memory(bytes).ok()?.to_rgba8())
|
||||
}
|
||||
|
||||
/// Straight RGBA → a premultiplied pixmap (max 16k per side).
|
||||
fn premultiplied(rgba: &image::RgbaImage) -> Option<Pixmap> {
|
||||
let (w, h) = rgba.dimensions();
|
||||
if w == 0 || h == 0 || w > 16_384 || h > 16_384 {
|
||||
return None;
|
||||
|
|
@ -50,10 +57,7 @@ pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile
|
|||
}
|
||||
let data = data?;
|
||||
let mut p = decode(&data.bytes)?;
|
||||
if !filters.is_identity() {
|
||||
apply_filters(&mut p, filters);
|
||||
}
|
||||
ColorProfile::Srgb.conversion_to(profile).apply_pixels(p.data_mut());
|
||||
finish(&mut p, filters, profile);
|
||||
let p = Arc::new(p);
|
||||
let mut c = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.len() > 256 {
|
||||
|
|
@ -63,6 +67,153 @@ pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile
|
|||
Some(p)
|
||||
}
|
||||
|
||||
/// The filters, then the conversion into the document's colour space.
|
||||
fn finish(p: &mut Pixmap, filters: &ImageFilters, profile: ColorProfile) {
|
||||
if !filters.is_identity() {
|
||||
apply_filters(p, filters);
|
||||
}
|
||||
ColorProfile::Srgb.conversion_to(profile).apply_pixels(p.data_mut());
|
||||
}
|
||||
|
||||
/// The most frames of an animated GIF that play.
|
||||
pub const MAX_FRAMES: usize = 500;
|
||||
/// The most decoded bytes an animated GIF's frames take (frames past it don't play).
|
||||
pub const MAX_ANIMATION_BYTES: u64 = 128 << 20;
|
||||
/// The most decoded bytes the cached animations take together.
|
||||
const MAX_CACHED_ANIMATION_BYTES: u64 = 256 << 20;
|
||||
|
||||
/// An animated GIF's frames (whole, composited), filtered and in one colour space.
|
||||
pub struct Animation {
|
||||
frames: Vec<Arc<Pixmap>>,
|
||||
/// When each frame ends, in seconds from the start of a loop.
|
||||
ends: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Animation {
|
||||
/// The frame showing `t` seconds in (the animation loops) and the seconds until the next one.
|
||||
pub fn at(&self, t: f64) -> (usize, f64) {
|
||||
let total = self.ends.last().copied().unwrap_or(0.0);
|
||||
if !t.is_finite() || t <= 0.0 || total <= 0.0 {
|
||||
return (0, self.ends.first().copied().unwrap_or(f64::INFINITY));
|
||||
}
|
||||
let t = t.rem_euclid(total);
|
||||
let i = self.ends.partition_point(|e| *e <= t).min(self.ends.len().saturating_sub(1));
|
||||
(i, self.ends.get(i).map_or(f64::INFINITY, |e| e - t))
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.frames.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.frames.is_empty()
|
||||
}
|
||||
|
||||
fn bytes(&self) -> u64 {
|
||||
self.frames.iter().map(|f| f.data().len() as u64).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// A GIF frame's delay in seconds. Delays under 20 ms play as 100 ms, as browsers do (many GIFs
|
||||
/// say 0 and mean "the default").
|
||||
pub fn frame_secs(numer: u32, denom: u32) -> f64 {
|
||||
let ms = if denom == 0 { 0.0 } else { f64::from(numer) / f64::from(denom) };
|
||||
if ms < 20.0 { 0.1 } else { ms / 1000.0 }
|
||||
}
|
||||
|
||||
/// Every frame of an animated GIF with its delay; `None` for other images, stills and GIFs too
|
||||
/// big for one frame within [`MAX_ANIMATION_BYTES`]. A damaged frame ends the animation there.
|
||||
/// shortcut: GIFs always loop (their repeat count is ignored); read it if a one-shot GIF matters.
|
||||
pub fn decode_frames(bytes: &[u8]) -> Option<Vec<(Pixmap, f64)>> {
|
||||
use image::{AnimationDecoder, ImageDecoder};
|
||||
if image::guess_format(bytes).ok()? != image::ImageFormat::Gif {
|
||||
return None;
|
||||
}
|
||||
let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).ok()?;
|
||||
let (w, h) = dec.dimensions();
|
||||
let frame_bytes = u64::from(w) * u64::from(h) * 4;
|
||||
if frame_bytes == 0 || w > 16_384 || h > 16_384 {
|
||||
return None;
|
||||
}
|
||||
let most = (MAX_ANIMATION_BYTES / frame_bytes).min(MAX_FRAMES as u64) as usize;
|
||||
let mut out = Vec::new();
|
||||
for f in dec.into_frames().take(most) {
|
||||
let Ok(f) = f else { break };
|
||||
let (n, d) = f.delay().numer_denom_ms();
|
||||
out.push((premultiplied(f.buffer())?, frame_secs(n, d)));
|
||||
}
|
||||
(out.len() > 1).then_some(out)
|
||||
}
|
||||
|
||||
type AnimKey = (String, [i32; 7], ColorProfile);
|
||||
type AnimCache = HashMap<AnimKey, Option<Arc<Animation>>>;
|
||||
|
||||
fn anim_cache() -> &'static Mutex<AnimCache> {
|
||||
static C: OnceLock<Mutex<AnimCache>> = OnceLock::new();
|
||||
C.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// The animated GIF `hash`'s frames for `filters` and `profile`, decoded on first use and cached;
|
||||
/// `None` for anything that doesn't move.
|
||||
pub fn animation(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile) -> Option<Arc<Animation>> {
|
||||
let key = (hash.to_string(), fkey(filters), profile);
|
||||
if let Some(a) = anim_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
|
||||
return a.clone();
|
||||
}
|
||||
let a = decode_frames(&data?.bytes).map(|frames| {
|
||||
let mut end = 0.0;
|
||||
let (frames, ends) = frames
|
||||
.into_iter()
|
||||
.map(|(mut p, secs)| {
|
||||
finish(&mut p, filters, profile);
|
||||
end += secs;
|
||||
(Arc::new(p), end)
|
||||
})
|
||||
.unzip();
|
||||
Arc::new(Animation { frames, ends })
|
||||
});
|
||||
let mut c = anim_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let held: u64 = c.values().flatten().map(|a| a.bytes()).sum();
|
||||
if held + a.as_ref().map_or(0, |a| a.bytes()) > MAX_CACHED_ANIMATION_BYTES || c.len() > 256 {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(key, a.clone());
|
||||
a
|
||||
}
|
||||
|
||||
/// The frame an animated GIF shows `t` seconds in, when that isn't its first frame (the still
|
||||
/// image every other path draws).
|
||||
pub fn frame_at(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile, t: f64) -> Option<Arc<Pixmap>> {
|
||||
if t.is_nan() || t <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let a = animation(hash, data, filters, profile)?;
|
||||
match a.at(t).0 {
|
||||
0 => None,
|
||||
i => a.frames.get(i).cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the animated GIFs in `root`'s visible fills and strokes are `t` seconds in: a number for
|
||||
/// the frames they show (a cache key) and the seconds until one of them changes. `None` when
|
||||
/// nothing there moves.
|
||||
/// shortcut: text runs with their own image fills aren't looked at; add them if anyone animates text.
|
||||
pub fn animation_clock(doc: &Document, root: NodeId, t: f64) -> Option<(u64, f64)> {
|
||||
let mut clock: Option<(u64, f64)> = None;
|
||||
for n in doc.subtree(root).into_iter().filter_map(|id| doc.node(id)).filter(|n| n.visible) {
|
||||
for p in n.fills.iter().chain(&n.strokes).filter(|p| p.visible) {
|
||||
if let PaintKind::Image(img) = &p.kind
|
||||
&& let Some(a) = animation(&img.image, doc.images.get(&img.image), &img.filters, doc.color_profile)
|
||||
{
|
||||
let (i, next) = a.at(t);
|
||||
let (key, soonest) = clock.unwrap_or((0, f64::INFINITY));
|
||||
clock = Some((key.wrapping_mul(31).wrapping_add(i as u64 + 1), soonest.min(next)));
|
||||
}
|
||||
}
|
||||
}
|
||||
clock
|
||||
}
|
||||
|
||||
/// Image adjustments (approximations of exposure, contrast, saturation, temperature, tint,
|
||||
/// highlights and shadows) on a premultiplied pixmap.
|
||||
pub fn apply_filters(p: &mut Pixmap, f: &ImageFilters) {
|
||||
|
|
@ -112,6 +263,13 @@ pub fn dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
|||
reader.into_dimensions().ok()
|
||||
}
|
||||
|
||||
/// `pm` with a transparent 1-pixel border.
|
||||
pub fn pad(pm: &Pixmap) -> Option<Pixmap> {
|
||||
let mut out = Pixmap::new(pm.width() + 2, pm.height() + 2)?;
|
||||
out.draw_pixmap(1, 1, pm.as_ref(), &tiny_skia::PixmapPaint::default(), tiny_skia::Transform::identity(), None);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn padded_cache() -> &'static Mutex<HashMap<Key, Arc<Pixmap>>> {
|
||||
static C: OnceLock<Mutex<HashMap<Key, Arc<Pixmap>>>> = OnceLock::new();
|
||||
C.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
|
|
@ -123,9 +281,7 @@ pub fn padded(hash: &str, filters: &ImageFilters, profile: ColorProfile, pm: &Pi
|
|||
if let Some(p) = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
|
||||
return Some(p.clone());
|
||||
}
|
||||
let mut out = Pixmap::new(pm.width() + 2, pm.height() + 2)?;
|
||||
out.draw_pixmap(1, 1, pm.as_ref(), &tiny_skia::PixmapPaint::default(), tiny_skia::Transform::identity(), None);
|
||||
let out = Arc::new(out);
|
||||
let out = Arc::new(pad(pm)?);
|
||||
let mut c = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.len() > 256 {
|
||||
c.clear();
|
||||
|
|
|
|||
|
|
@ -153,7 +153,16 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
|
|||
pix.fill(paint::ts_color(bg));
|
||||
}
|
||||
let lod = opts.lod && !opts.outline_mode && view.scale <= lod::MAX_SCALE;
|
||||
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: opts.outline_mode, outlined: &opts.outlined, skip: &opts.skip, lod, under: None };
|
||||
let ctx = draw::Ctx {
|
||||
doc,
|
||||
scale: view.scale,
|
||||
outline_mode: opts.outline_mode,
|
||||
outlined: &opts.outlined,
|
||||
skip: &opts.skip,
|
||||
lod,
|
||||
under: None,
|
||||
time: 0.0,
|
||||
};
|
||||
let xf = view.transform();
|
||||
let Some(p) = doc.node(page) else { return Some(pix) };
|
||||
match opts.solo.filter(|id| *id != page && doc.page_of(*id) == Some(page)) {
|
||||
|
|
@ -169,7 +178,12 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
|
|||
|
||||
/// Render several nodes (in z-order) into an existing pixmap through `view`.
|
||||
pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) {
|
||||
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, outlined: &[], skip: &[], lod: false, under: None };
|
||||
nodes_into(doc, ids, view, pix, 0.0);
|
||||
}
|
||||
|
||||
/// [`render_nodes_into`] `time` seconds into animated GIFs.
|
||||
fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64) {
|
||||
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, outlined: &[], skip: &[], lod: false, under: None, time };
|
||||
for id in ids {
|
||||
draw::draw_node(&ctx, pix, *id, view.transform() * parent_transform(doc, *id), 0);
|
||||
}
|
||||
|
|
@ -235,6 +249,11 @@ pub fn export_bounds(doc: &Document, id: NodeId) -> Option<Rect> {
|
|||
|
||||
/// Rasterise one node at `scale` (export). Transparent background.
|
||||
pub fn render_node(doc: &Document, id: NodeId, scale: f64) -> Option<Pixmap> {
|
||||
render_node_at(doc, id, scale, 0.0)
|
||||
}
|
||||
|
||||
/// [`render_node`] `time` seconds into animated GIFs (the presenter plays them).
|
||||
pub fn render_node_at(doc: &Document, id: NodeId, scale: f64, time: f64) -> Option<Pixmap> {
|
||||
let b = export_bounds(doc, id)?;
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale.min(64.0) } else { 1.0 };
|
||||
let w = (b.width() * scale - 1e-6).ceil().max(1.0);
|
||||
|
|
@ -244,7 +263,7 @@ pub fn render_node(doc: &Document, id: NodeId, scale: f64) -> Option<Pixmap> {
|
|||
}
|
||||
let view = View { origin: (b.x0, b.y0), scale, width: w as u32, height: h as u32 };
|
||||
let mut pix = new_pixmap(view.width, view.height)?;
|
||||
render_nodes_into(doc, &[id], &view, &mut pix);
|
||||
nodes_into(doc, &[id], &view, &mut pix, time);
|
||||
Some(pix)
|
||||
}
|
||||
|
||||
|
|
@ -383,6 +402,8 @@ mod boolean_cache_tests;
|
|||
#[cfg(test)]
|
||||
mod effect_order_tests;
|
||||
#[cfg(test)]
|
||||
mod gif_tests;
|
||||
#[cfg(test)]
|
||||
mod grain_tests;
|
||||
#[cfg(test)]
|
||||
mod lod_tests;
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ fn exports_never_use_lod() {
|
|||
crate::render_nodes_into(&d, &[card], &v, &mut exact);
|
||||
assert_eq!(png.data(), exact.data());
|
||||
let mut preview = Pixmap::new(v.width, v.height).unwrap();
|
||||
let ctx = crate::draw::Ctx { doc: &d, scale, outline_mode: false, outlined: &[], skip: &[], lod: true, under: None };
|
||||
let ctx = crate::draw::Ctx { doc: &d, scale, outline_mode: false, outlined: &[], skip: &[], lod: true, under: None, time: 0.0 };
|
||||
let parent = d.world_transform(p);
|
||||
crate::draw::draw_node(&ctx, &mut preview, card, v.transform() * parent, 0);
|
||||
assert_ne!(png.data(), preview.data(), "the preview of the same area is approximated");
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ fn diamond_pixmap(g: &Gradient, opacity: f32) -> Option<Arc<Pixmap>> {
|
|||
/// Build the shader for `paint` on a `w`×`h` local box. Returns `None` when the paint draws
|
||||
/// nothing (invisible, fully transparent, missing image).
|
||||
pub fn prepare(paint: &Paint, w: f64, h: f64, doc: &Document) -> Option<Prepared> {
|
||||
prepare_at(paint, w, h, doc, 0.0)
|
||||
}
|
||||
|
||||
/// [`prepare`] `time` seconds into animated GIFs (0: their first frame).
|
||||
pub fn prepare_at(paint: &Paint, w: f64, h: f64, doc: &Document, time: f64) -> Option<Prepared> {
|
||||
if !paint.visible || paint.opacity <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -121,13 +126,21 @@ pub fn prepare(paint: &Paint, w: f64, h: f64, doc: &Document) -> Option<Prepared
|
|||
}
|
||||
PaintKind::Image(img) => {
|
||||
let data = doc.images.get(&img.image);
|
||||
let pm = crate::images::get(&img.image, data, &img.filters, doc.color_profile)?;
|
||||
let moving = crate::images::frame_at(&img.image, data, &img.filters, doc.color_profile, time);
|
||||
let pm = match &moving {
|
||||
Some(f) => f.clone(),
|
||||
None => crate::images::get(&img.image, data, &img.filters, doc.color_profile)?,
|
||||
};
|
||||
let transform = image_transform(img, f64::from(pm.width()), f64::from(pm.height()), w, h);
|
||||
if img.scale_mode == ImageScaleMode::Tile {
|
||||
return Some(Prepared::Pattern { pixmap: pm, mode: SpreadMode::Repeat, quality: FilterQuality::Bicubic, opacity: op, transform });
|
||||
}
|
||||
// Fit/Fill/Crop: a transparent 1-px border so Pad spreads transparency, not edge pixels.
|
||||
let padded = crate::images::padded(&img.image, &img.filters, doc.color_profile, &pm)?;
|
||||
// A GIF's later frames each show once per loop: not worth caching padded.
|
||||
let padded = match moving {
|
||||
Some(_) => Arc::new(crate::images::pad(&pm)?),
|
||||
None => crate::images::padded(&img.image, &img.filters, doc.color_profile, &pm)?,
|
||||
};
|
||||
let transform = Transform::from_translate(-1.0, -1.0).post_concat(transform);
|
||||
Some(Prepared::Pattern { pixmap: padded, mode: SpreadMode::Pad, quality: FilterQuality::Bicubic, opacity: op, transform })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,8 +185,16 @@ impl W<'_> {
|
|||
let data = self.doc.images.get(&img.image)?;
|
||||
let pid = self.id("pattern");
|
||||
let iid = self.id("image");
|
||||
let mime = sniff_mime(&data.bytes);
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(data.bytes.as_slice());
|
||||
// Formats SVG viewers don't show (TIFF, BMP) go in as PNG.
|
||||
let png;
|
||||
let (mime, bytes) = match sniff_mime(&data.bytes) {
|
||||
"application/octet-stream" => {
|
||||
png = figcraft_render::images::decode(&data.bytes).and_then(|p| figcraft_render::encode_png(&p).ok())?;
|
||||
("image/png", png.as_slice())
|
||||
}
|
||||
m => (m, data.bytes.as_slice()),
|
||||
};
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
let t = figcraft_render::paint::image_transform(img, f64::from(data.width), f64::from(data.height), w, h);
|
||||
let m = Affine::new([f64::from(t.sx), f64::from(t.ky), f64::from(t.kx), f64::from(t.sy), f64::from(t.tx), f64::from(t.ty)]);
|
||||
let (pw, ph) = if img.scale_mode == figcraft_color::ImageScaleMode::Tile {
|
||||
|
|
|
|||
|
|
@ -419,7 +419,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
|||
0 => PaintKind::Solid { color: base.with_alpha(1.0) },
|
||||
5 => {
|
||||
// Pick an image file.
|
||||
match app.services.pick_open("Choose image", &["png", "jpg", "jpeg", "webp", "gif"]) {
|
||||
match app.services.pick_open("Choose image", &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]) {
|
||||
Some(p) => match std::fs::read(&p) {
|
||||
Ok(bytes) => {
|
||||
let hash = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes));
|
||||
|
|
|
|||
|
|
@ -237,3 +237,39 @@ fn control_requests_and_screenshots_wake_an_idle_ui() {
|
|||
let _ = std::fs::remove_file(&path);
|
||||
h.assert_idle("after a screenshot");
|
||||
}
|
||||
|
||||
/// A synthetic animated GIF: red, then green, 100 ms each.
|
||||
fn animated_gif() -> String {
|
||||
use base64::Engine as _;
|
||||
use image::codecs::gif::{GifEncoder, Repeat};
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut enc = GifEncoder::new(&mut out);
|
||||
enc.set_repeat(Repeat::Infinite).unwrap();
|
||||
let frame = |c: [u8; 4]| {
|
||||
image::Frame::from_parts(image::RgbaImage::from_pixel(8, 8, image::Rgba(c)), 0, 0, image::Delay::from_numer_denom_ms(100, 1))
|
||||
};
|
||||
enc.encode_frames([frame([255, 0, 0, 255]), frame([0, 255, 0, 255])]).unwrap();
|
||||
}
|
||||
base64::engine::general_purpose::STANDARD.encode(out)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_animated_gif_wakes_only_the_presenter_and_only_at_its_frames() {
|
||||
let mut s = Session::new();
|
||||
let a = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap());
|
||||
s.execute("image.place", &json!({"data": animated_gif(), "parent": a, "x": 10, "y": 10, "width": 100, "height": 100})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
let mut h = Harness::new(s);
|
||||
h.assert_idle("an animated GIF on the canvas (it shows its first frame)");
|
||||
h.run("proto.present", json!({"start": a}));
|
||||
let delays: Vec<Duration> = (0..30).map(|_| h.frame(vec![])).collect();
|
||||
// Past the first frames (the presenter opening), it wakes for the GIF's next frame: no sooner
|
||||
// than needed, never later than one GIF frame (plus the display frame egui wakes early by).
|
||||
for d in &delays[5..] {
|
||||
assert!(*d <= Duration::from_millis(100 + 17), "{delays:?}");
|
||||
}
|
||||
assert!(delays[5..].iter().any(|d| *d > Duration::from_millis(20)), "it doesn't draw every frame: {delays:?}");
|
||||
h.app.ui.present = None;
|
||||
h.assert_idle("the presenter closed");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -568,7 +568,7 @@ impl FigApp {
|
|||
}
|
||||
|
||||
pub fn open_dialog(&mut self) {
|
||||
if let Some(p) = self.services.pick_open("Open", &["figcraft", "json", "svg", "png", "jpg", "jpeg", "webp", "gif"]) {
|
||||
if let Some(p) = self.services.pick_open("Open", &["figcraft", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]) {
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
if self.run("file.open", json!({"path": ps})).is_some() {
|
||||
self.ui.home = false;
|
||||
|
|
@ -577,7 +577,11 @@ impl FigApp {
|
|||
}
|
||||
|
||||
pub fn import_dialog(&mut self, place: bool) {
|
||||
let exts: &[&str] = if place { &["png", "jpg", "jpeg", "webp", "gif"] } else { &["svg", "png", "jpg", "jpeg", "webp", "gif", "figcraft"] };
|
||||
let exts: &[&str] = if place {
|
||||
&["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]
|
||||
} else {
|
||||
&["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft"]
|
||||
};
|
||||
if let Some(p) = self.services.pick_open(if place { "Place image" } else { "Import" }, exts) {
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
self.run("file.import", json!({"path": ps}));
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@ pub struct OverlayState {
|
|||
|
||||
#[derive(Clone)]
|
||||
struct FrameTex {
|
||||
key: (u64, u64),
|
||||
/// The player's revision, the pixel scale and the frames its animated GIFs show.
|
||||
key: (u64, u64, u64),
|
||||
tex: egui::TextureHandle,
|
||||
/// Page-space rect the texture covers.
|
||||
bounds: figcraft_geom::Rect,
|
||||
|
|
@ -811,15 +812,24 @@ impl Presenter {
|
|||
}
|
||||
}
|
||||
|
||||
fn texture(&mut self, ctx: &egui::Context, doc: &Document, id: NodeId, px_scale: f64) -> Option<FrameTex> {
|
||||
let key = (self.rev, px_scale.to_bits());
|
||||
fn texture(&mut self, ctx: &egui::Context, doc: &Document, id: NodeId, px_scale: f64, now: f64) -> Option<FrameTex> {
|
||||
// Animated GIFs play from when the frame arrived: a new texture when one of them moves on,
|
||||
// and a wake-up for that moment (nothing else draws frames while it shows). egui wakes a
|
||||
// display frame before the time asked for, a little earlier still at times: ask that much
|
||||
// later, and count a frame due within a few milliseconds as due.
|
||||
let t = (now - self.arrived).max(0.0) + 0.005;
|
||||
let clock = figcraft_render::images::animation_clock(doc, id, t);
|
||||
if let Some((_, next)) = clock {
|
||||
crate::repaint_in(ctx, next + f64::from(ctx.input(|i| i.predicted_dt)));
|
||||
}
|
||||
let key = (self.rev, px_scale.to_bits(), clock.map_or(0, |c| c.0));
|
||||
if let Some(t) = self.textures.get(&id)
|
||||
&& t.key == key
|
||||
{
|
||||
return Some(t.clone());
|
||||
}
|
||||
let bounds = figcraft_render::export_bounds(doc, id)?;
|
||||
let pix = figcraft_render::render_node(doc, id, texture_scale(ctx, bounds, px_scale))?;
|
||||
let pix = figcraft_render::render_node_at(doc, id, texture_scale(ctx, bounds, px_scale), t)?;
|
||||
let img = crate::render_worker::screen_image(&pix, doc.color_profile);
|
||||
let tex = ctx.load_texture(format!("present-{}", id.0), img, egui::TextureOptions::LINEAR);
|
||||
let ft = FrameTex { key, tex, bounds };
|
||||
|
|
@ -2340,7 +2350,7 @@ fn draw(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Lay
|
|||
let ppp = f64::from(ctx.pixels_per_point());
|
||||
let px = l.scale * ppp;
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
|
||||
let to_tex = p.texture(ctx, &l.disp, p.current, px);
|
||||
let to_tex = p.texture(ctx, &l.disp, p.current, px, now);
|
||||
// Transition progress.
|
||||
let mut drawn = false;
|
||||
if let Some(a) = &p.anim {
|
||||
|
|
@ -2436,7 +2446,7 @@ fn draw(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Lay
|
|||
let c = crate::theme::doc_c32(ctx, bgc);
|
||||
on_screen.rect_filled(l.screen, CornerRadius::ZERO, c.gamma_multiply(raw as f32));
|
||||
}
|
||||
if let Some(tex) = p.texture(ctx, &l.disp, *id, px) {
|
||||
if let Some(tex) = p.texture(ctx, &l.disp, *id, px, now) {
|
||||
let r = place.rect(tex.bounds).translate(vec2(orect.width() * f.to_off.0, orect.height() * f.to_off.1));
|
||||
on_screen.image(tex.tex.id(), r, uv, Color32::WHITE.gamma_multiply(f.to_alpha));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ figcraft-cli export app.figcraft -o poster.pdf --node "Poster" --bleed 3mm --cro
|
|||
### `convert <in> <out>`
|
||||
|
||||
By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.svg` (an editable frame: groups,
|
||||
shapes, text, gradients, masks and effects; `info` lists what it left out as `note:` lines), `.png/.jpg/.webp/.gif/.bmp`
|
||||
shapes, text, gradients, masks and effects; `info` lists what it left out as `note:` lines), `.png/.jpg/.webp/.gif/.tif/.bmp`
|
||||
(an image layer). Outputs: `.figcraft`, `.json` (FigCraft JSON, pretty-printed), `.svg`,
|
||||
`.png/.jpg/.webp` (the current page rendered at 1x with its background), `.pdf` (the current page's
|
||||
frames, one page each).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
|
||||
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
|
||||
|
|
@ -26,12 +26,12 @@ alpha.
|
|||
|---|---|---|---|---|
|
||||
| SVG | editable vectors | **yes**, editable | corpus of 21 synthetic files with hand-computed expected pixels, structure and round-trip checks; gradients, patterns as image fills, clip paths, masks, shadow/blur filters, blend modes, text as text layers; other filters dropped with a warning; text on a path as outlines; fuzzed | 2–4 (filters) |
|
||||
| PNG, JPG | yes | yes | | — |
|
||||
| GIF | yes (animated) | first frame only | | 2–4 |
|
||||
| GIF | yes (animated) | **yes**, animated | the file is kept as is; the canvas and PNG, JPG and PDF exports show the first frame (SVG exports embed the file, so viewers that animate GIFs animate it), the presenter and the preview play it (frames decoded on first play: at most 500 frames and 128 MB, past that the rest don't play; always loops) | — |
|
||||
| WebP | yes | yes | | — |
|
||||
| HEIC | yes | **no** | needs a decoder with an acceptable licence | 2–3 |
|
||||
| TIFF | Safari only | **no** | | 1 |
|
||||
| HEIC | yes | **no** | shortcut: no Rust decoder; the usable ones (libheif, libde265) are C libraries, and HEVC carries patents. Revisit when a Rust decoder exists | 2–3 |
|
||||
| TIFF | Safari only | **yes** | open, import, place, drop (the `image` crate's TIFF decoder); SVG exports embed it (and BMP) as PNG, which SVG viewers show | — |
|
||||
| BMP | — | yes | | — |
|
||||
| MP4, MOV, WebM (video fills, paid plans) | yes | **no** | presenter playback is most of the work | 4–8 |
|
||||
| MP4, MOV, WebM (video fills, paid plans) | yes | **no** | shortcut: decoding H.264/HEVC/VP9 needs non-Rust libraries (FFmpeg or each platform's media framework); presenter playback would reuse the animated GIF clock | 4–8 |
|
||||
| Images over 4096 px | downscaled to 4096 | kept | | — |
|
||||
| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text (newest clipboard content wins) | no Figma clipboard (it carries Figma's own binary) | with `.fig` |
|
||||
|
||||
|
|
@ -61,4 +61,5 @@ alpha.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-10 | major | Created from the 2026-10-10 re-measure |
|
||||
|
|
|
|||
14
docs/gaps.md
14
docs/gaps.md
|
|
@ -1,6 +1,6 @@
|
|||
# Where FigCraft falls short of Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
|
||||
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
|
||||
|
|
@ -25,7 +25,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
| 9 | Figma Draw: brushes, pressure, text on a path, pattern fills | Feature | 10–18 | [target-app-parity](target-app-parity.md) |
|
||||
| 10 | Shared (team) libraries | Feature | 10–20 | [components-parity](components-parity.md) |
|
||||
| 11 | Spell check | Feature | 4–8 | [target-app-parity](target-app-parity.md) |
|
||||
| 12 | Video and animated GIF fills; HEIC/TIFF images | File format | 6–12 | [file-format-parity](file-format-parity.md) |
|
||||
| 12 | Video fills; HEIC images | File format | 5–10 | [file-format-parity](file-format-parity.md) |
|
||||
| 13 | Boolean groups hit-tested by their operands (the rest of the vector tail landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) |
|
||||
| 14 | Variables: extended collections' tail | Feature | 1–2 | [components-parity](components-parity.md) |
|
||||
| 15 | Prototyping: media triggers and actions, gamepad | Feature | 3–6 | [prototyping-parity](prototyping-parity.md) |
|
||||
|
|
@ -159,10 +159,13 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
- **Estimate:** 4–8 h with a permissively licensed dictionary and checker (provenance per
|
||||
`ATTRIBUTION.md`).
|
||||
|
||||
### 12. Video and animated GIF fills; HEIC/TIFF images
|
||||
### 12. Video fills; HEIC images
|
||||
- **Landed (2026-10-11):** animated GIF fills (the file kept as is, the first frame on the canvas
|
||||
and in exports, played in the presenter and the preview) and TIFF images.
|
||||
- **Missing:** Figma takes JPG, PNG, HEIC, WebP, GIF and TIFF images and MP4, MOV and WebM video
|
||||
(help center, checked 2026-10-10). FigCraft decodes PNG, JPEG, WebP, GIF (first frame) and BMP.
|
||||
- **Estimate:** 6–12 h (video playback in the presenter is most of it).
|
||||
(help center, checked 2026-10-10). FigCraft decodes PNG, JPEG, WebP, GIF, TIFF and BMP; HEIC and
|
||||
video need decoders that are C libraries (libheif, FFmpeg) or each platform's media framework.
|
||||
- **Estimate:** 5–10 h (video playback in the presenter is most of it; it can reuse the GIF clock).
|
||||
|
||||
### 13. Boolean groups hit-tested by their operands
|
||||
- **Landed (2026-10-11):** live boolean editing (operands selected by double-click or Cmd/Ctrl-click;
|
||||
|
|
@ -239,6 +242,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h |
|
||||
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> **Generated:** 2026-10-11 by `cargo xtask parity`. Do not edit by hand.
|
||||
|
||||
FigCraft commands registered for the incumbent's main menu and toolbar (468 commands in the engine).
|
||||
FigCraft commands registered for the incumbent's main menu and toolbar (472 commands in the engine).
|
||||
|
||||
**203 / 203 menu items (100.0%)**
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
|
|
@ -52,7 +52,7 @@ blockers.
|
|||
| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 |
|
||||
| M4 Vector & text depth | ~75% (live booleans, arc fields, shape builder on strokes, one-sided widths landed 2026-10-11) | spell check, Figma Draw (brushes, pressure, text on a path, patterns), boolean hit testing on the result | 18–32 |
|
||||
| M5 Prototype & present | ~85% | media, gamepad, separate window | 5–9 |
|
||||
| M6 Interop & Dev Mode | ~55% | `.fig` import, video/HEIC/TIFF, CMYK intent, Code Connect | 40–80 |
|
||||
| M6 Interop & Dev Mode | ~55% | `.fig` import, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent, Code Connect | 40–80 |
|
||||
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
|
||||
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 |
|
||||
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable) | accounts, teams, TLS in the desktop app, packaging the server | 45–90 |
|
||||
|
|
@ -71,6 +71,7 @@ blockers.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h |
|
||||
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
|
||||
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
|
||||
|
|
@ -98,7 +98,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| 2 | Selection & transform | 8 | 95% | 68% | Handles, measuring and snapping checked tool by tool against Figma; rotated groups and mixed selections | 6–10 |
|
||||
| 3 | Layers panel | 5 | 95% | 78% | Keyboard box selection, keyboard rename/context menu, long-list QA | 2–4 |
|
||||
| 4 | Shapes & vector | 8 | 97% | 68% | Boolean groups hit-tested by their operands rather than their result; vector QA with real files (live boolean editing, arc fields, shape builder on strokes and open paths and one-sided widths landed) | 1–3 |
|
||||
| 5 | Paint | 7 | 85% | 66% | Video and animated GIF fills, wide-gamut canvas, embedded image profiles, HEIC/TIFF images | 5–9 |
|
||||
| 5 | Paint | 7 | 87% | 66% | Video fills, wide-gamut canvas, embedded image profiles, HEIC images (animated GIF fills and TIFF images landed) | 4–7 |
|
||||
| 6 | Strokes | 3 | 90% | 72% | Stroke styles, brush strokes (see 20) | 2–4 |
|
||||
| 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 |
|
||||
| 8 | Frames, groups, sections, masks | 5 | 95% | 75% | Visual QA with real files | 2–4 |
|
||||
|
|
@ -107,7 +107,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| 11 | Components & variants | 9 | 95% | 68% | Real (not synthetic) libraries, `.fig` libraries, component playground ([components-parity](components-parity.md)) | 6–12 |
|
||||
| 12 | Styles & variables | 5 | 92% | 72% | New library variables reaching extended collections on their own, extending an extended collection, real-library QA (extended collections, variables import/export, gradient stops and variant properties bound landed) | 1–2 |
|
||||
| 13 | Prototyping | 6 | 88% | 70% | Video triggers and media actions, gamepad triggers, real prototype QA ([prototyping-parity](prototyping-parity.md)) | 5–9 |
|
||||
| 14 | Import/export | 6 | 65% | 35% | **`.fig` import** (the blocking gap), `.fig` export, HEIC/TIFF/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 30–60 |
|
||||
| 14 | Import/export | 6 | 65% | 35% | **`.fig` import** (the blocking gap), `.fig` export, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 30–60 |
|
||||
| 15 | Dev Mode / inspect | 2 | 80% | 62% | Code Connect, plugins in Dev Mode, editor integrations ([dev-mode-parity](dev-mode-parity.md)) | 4–8 |
|
||||
| 16 | Comments, versions, find, misc | 3 | 80% | 58% | Comments are local only; branching | 4–8 |
|
||||
| 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat, TLS in the desktop app ([collaboration-parity](collaboration-parity.md)) | 45–90 |
|
||||
|
|
@ -577,6 +577,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h |
|
||||
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
|
||||
|
|
|
|||
Loading…
Reference in New Issue