Add fork features: host view sync, grouping, image compress, freistellen

Host can force all clients to their camera; selection Group/Ungroup with
synced groupIds; WebP/PNG preprocess and Tower rembg/white-key freistellen
on insert or via selection actions (save format 0.7 / INFPNT000007).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
copilot 2026-09-21 16:28:47 +02:00
parent df49687f51
commit 042fe8f5a6
19 changed files with 672 additions and 9 deletions

View file

@ -49,7 +49,7 @@ CanvasComponentContainer::CanvasComponentContainer(NetworkingObjects::NetObjMana
void CanvasComponentContainer::register_class(World& w) {
auto readConstructorFunc = [&w](const NetworkingObjects::NetObjTemporaryPtr<CanvasComponentContainer>& o, cereal::PortableBinaryInputArchive& a, const std::shared_ptr<NetServer::ClientData>& c) {
a(o->coords);
a(o->coords, o->groupId);
o->compAllocator = o.get_obj_man()->read_create_message<CanvasComponentAllocator>(a, c);
o->compAllocator->comp->compContainer = o.get();
canvas_scale_up_check(*o, w, c);
@ -72,6 +72,7 @@ std::unique_ptr<CanvasComponentContainer::CopyData> CanvasComponentContainer::ge
auto toRet = std::make_unique<CanvasComponentContainer::CopyData>();
toRet->coords = coords;
toRet->obj = compAllocator->comp->get_data_copy();
// groupId intentionally not copied — paste creates independent objects
return toRet;
}
@ -84,18 +85,22 @@ void CanvasComponentContainer::set_object_update_lock(DrawingProgram& drawP, boo
}
void CanvasComponentContainer::write_constructor_func(const NetworkingObjects::NetObjTemporaryPtr<CanvasComponentContainer>& o, cereal::PortableBinaryOutputArchive& a) {
a(o->coords);
a(o->coords, o->groupId);
o->compAllocator.write_create_message(a);
}
void CanvasComponentContainer::save_file(cereal::PortableBinaryOutputArchive& a) const {
a(coords);
a(coords, groupId);
compAllocator->save_file(a);
}
void CanvasComponentContainer::load_file(cereal::PortableBinaryInputArchive& a, VersionNumber version, NetObjManager& objMan) {
if(version >= VersionNumber(0, 4, 0)) {
a(coords);
if(version >= VersionNumber(0, 7, 0))
a(groupId);
else
groupId = 0;
compAllocator = objMan.make_obj_direct<CanvasComponentAllocator>();
compAllocator->load_file(a, version);
compAllocator->comp->compContainer = this;
@ -104,6 +109,7 @@ void CanvasComponentContainer::load_file(cereal::PortableBinaryInputArchive& a,
CanvasComponentType t;
NetworkingObjects::NetObjID uselessID;
a(t, uselessID, coords);
groupId = 0;
compAllocator = objMan.make_obj_direct<CanvasComponentAllocator>(t);
compAllocator->comp->load_file(a, version);
compAllocator->comp->compContainer = this;

View file

@ -101,6 +101,8 @@ class CanvasComponentContainer {
std::weak_ptr<DrawingProgramCacheBVHNode> cacheParentBvhNode;
DrawingProgramLayerListItem* parentLayer = nullptr;
CoordSpaceHelper coords;
// 0 = ungrouped. Non-zero IDs move/transform together when any member is selected.
uint64_t groupId = 0;
ObjInfoIterator objInfo;
private:
friend class BrushStrokeCanvasComponent;

View file

@ -28,7 +28,9 @@ enum ServerCommands : MessageCommandType {
SERVER_UPDATE_NETWORK_OBJECT,
SERVER_NEW_RESOURCE_ID,
SERVER_NEW_RESOURCE_DATA,
SERVER_TRANSFORM_MANY_COMPONENTS
SERVER_TRANSFORM_MANY_COMPONENTS,
SERVER_FORCE_CAMERA_JUMP,
SERVER_SET_COMPONENT_GROUP_IDS
};
enum ClientCommands : MessageCommandType {
@ -39,5 +41,7 @@ enum ClientCommands : MessageCommandType {
CLIENT_UPDATE_NETWORK_OBJECT,
CLIENT_NEW_RESOURCE_ID,
CLIENT_NEW_RESOURCE_DATA,
CLIENT_TRANSFORM_MANY_COMPONENTS
CLIENT_TRANSFORM_MANY_COMPONENTS,
CLIENT_FORCE_CAMERA_JUMP,
CLIENT_SET_COMPONENT_GROUP_IDS
};

View file

@ -48,6 +48,12 @@
#include <chrono>
#include "../CanvasComponents/ImageCanvasComponent.hpp"
#include "Layers/DrawingProgramLayer.hpp"
#include "../ImagePreprocess.hpp"
#include <Helpers/Random.hpp>
#include <cereal/types/utility.hpp>
#ifdef __ANDROID__
#include "../AndroidJNICalls.hpp"
#endif
#include "Layers/DrawingProgramLayerListItem.hpp"
#include "../ScaleUpCanvas.hpp"
@ -336,6 +342,22 @@ void DrawingProgram::init_server_callbacks() {
process_transform_message(transforms);
world.netServer->send_items_to_all_clients(RELIABLE_COMMAND_CHANNEL, CLIENT_TRANSFORM_MANY_COMPONENTS, transforms);
});
world.netServer->add_recv_callback(SERVER_FORCE_CAMERA_JUMP, [&](std::shared_ptr<NetServer::ClientData> client, cereal::PortableBinaryInputArchive& message) {
CoordSpaceHelper coords;
Vector2f windowSize;
message(coords, windowSize);
// Only the host may force jumps; ignore client-originated requests.
(void)client;
(void)coords;
(void)windowSize;
});
world.netServer->add_recv_callback(SERVER_SET_COMPONENT_GROUP_IDS, [&](std::shared_ptr<NetServer::ClientData> client, cereal::PortableBinaryInputArchive& message) {
std::vector<std::pair<NetworkingObjects::NetObjID, uint64_t>> groupIds;
message(groupIds);
(void)client;
process_group_id_message(groupIds);
world.netServer->send_items_to_all_clients(RELIABLE_COMMAND_CHANNEL, CLIENT_SET_COMPONENT_GROUP_IDS, groupIds);
});
}
void DrawingProgram::init_client_callbacks() {
@ -344,6 +366,51 @@ void DrawingProgram::init_client_callbacks() {
message(transforms);
process_transform_message(transforms);
});
world.netClient->add_recv_callback(CLIENT_FORCE_CAMERA_JUMP, [&](cereal::PortableBinaryInputArchive& message) {
CoordSpaceHelper coords;
Vector2f windowSize;
message(coords, windowSize);
process_force_camera_jump(coords, windowSize);
});
world.netClient->add_recv_callback(CLIENT_SET_COMPONENT_GROUP_IDS, [&](cereal::PortableBinaryInputArchive& message) {
std::vector<std::pair<NetworkingObjects::NetObjID, uint64_t>> groupIds;
message(groupIds);
process_group_id_message(groupIds);
});
}
void DrawingProgram::process_force_camera_jump(const CoordSpaceHelper& coords, const Vector2f& windowSize) {
world.drawData.cam.smooth_move_to(world, coords, windowSize);
}
void DrawingProgram::force_clients_to_own_view() {
if(!world.netServer)
return;
CoordSpaceHelper coords = world.drawData.cam.c;
Vector2f windowSize = world.main.window.size.cast<float>().eval();
world.netServer->send_items_to_all_clients(RELIABLE_COMMAND_CHANNEL, CLIENT_FORCE_CAMERA_JUMP, coords, windowSize);
}
void DrawingProgram::process_group_id_message(const std::vector<std::pair<NetworkingObjects::NetObjID, uint64_t>>& groupIds) {
for(auto& [id, groupId] : groupIds) {
auto objPtr = world.netObjMan.get_obj_temporary_ref_from_id<CanvasComponentContainer>(id);
if(!objPtr)
continue;
objPtr->groupId = groupId;
}
world.set_to_layout_gui_if_focus();
}
void DrawingProgram::send_group_ids_for(const std::vector<CanvasComponentContainer::ObjInfo*>& objs) {
if(!world.netObjMan.is_connected() || objs.empty())
return;
std::vector<std::pair<NetworkingObjects::NetObjID, uint64_t>> groupIds;
for(auto& obj : objs)
groupIds.emplace_back(obj->obj.get_net_id(), obj->obj->groupId);
if(world.netObjMan.is_server())
world.netServer->send_items_to_all_clients(RELIABLE_COMMAND_CHANNEL, CLIENT_SET_COMPONENT_GROUP_IDS, groupIds);
else
world.netClient->send_items_to_server(RELIABLE_COMMAND_CHANNEL, SERVER_SET_COMPONENT_GROUP_IDS, groupIds);
}
void DrawingProgram::process_transform_message(const std::vector<std::pair<NetworkingObjects::NetObjID, CoordSpaceHelper>>& transforms) {
@ -726,7 +793,23 @@ void DrawingProgram::input_add_file_to_canvas_callback(const CustomEvents::AddFi
void DrawingProgram::add_file_to_canvas_by_path(const std::filesystem::path& filePath, Vector2f dropPos) {
if(layerMan.is_a_layer_being_edited()) {
NetworkingObjects::NetObjTemporaryPtr<ResourceData> imageTempPtr = world.rMan.add_resource_file(filePath);
ResourceData resource;
#ifdef __ANDROID__
resource.name = AndroidJNICalls::getFileNameFromURI(filePath.string());
if(resource.name.empty())
resource.name = "New Resource";
#else
resource.name = std::filesystem::path(filePath).filename().string();
#endif
try {
resource.data = std::make_shared<std::string>(read_file_to_string(filePath));
}
catch(...) {
Logger::get().log(Logger::LogType::INFO, "[DrawingProgram::add_file_to_canvas_by_path] Could not open file " + filePath.string());
return;
}
ImagePreprocess::preprocess_resource(resource.name, resource.data, ImagePreprocess::options_from_config(world.main.conf));
NetworkingObjects::NetObjTemporaryPtr<ResourceData> imageTempPtr = world.rMan.add_resource(resource);
if(imageTempPtr) {
NetworkingObjects::NetObjID imageID = imageTempPtr.get_net_id();
ResourceDisplay* display = world.rMan.get_display_data(imageID);
@ -753,6 +836,7 @@ CanvasComponentContainer::ObjInfo* DrawingProgram::add_file_to_canvas_by_data(co
ResourceData newResource;
newResource.data = std::make_shared<std::string>(fileBuffer);
newResource.name = fileName;
ImagePreprocess::preprocess_resource(newResource.name, newResource.data, ImagePreprocess::options_from_config(world.main.conf));
NetworkingObjects::NetObjID imageID = world.rMan.add_resource(newResource).get_net_id();
ResourceDisplay* display = world.rMan.get_display_data(imageID);
Vector2f imTrueDim = display->get_dimensions();
@ -772,6 +856,32 @@ CanvasComponentContainer::ObjInfo* DrawingProgram::add_file_to_canvas_by_data(co
return nullptr;
}
void DrawingProgram::process_selected_images(bool freistellen, bool compress) {
auto selected = selection.get_selection_as_set();
ImagePreprocess::Options opts = ImagePreprocess::options_from_config(world.main.conf);
opts.freistellenOnInsert = freistellen;
opts.compressOnInsert = compress;
if(!freistellen && !compress)
return;
for(auto* objInfo : selected) {
if(objInfo->obj->get_comp().get_type() != CanvasComponentType::IMAGE)
continue;
auto& img = static_cast<ImageCanvasComponent&>(objInfo->obj->get_comp());
auto resource = world.netObjMan.get_obj_temporary_ref_from_id<ResourceData>(img.d.imageID);
if(!resource || !resource->data)
continue;
ResourceData newRes;
newRes.name = resource->name;
newRes.data = std::make_shared<std::string>(*resource->data);
if(!ImagePreprocess::preprocess_resource(newRes.name, newRes.data, opts))
continue;
img.d.imageID = world.rMan.add_resource(newRes).get_net_id();
objInfo->obj->send_comp_update(*this, true);
objInfo->obj->commit_update(*this);
}
world.rMan.clear_display_cache();
}
float DrawingProgram::drag_point_radius() {
return (world.main.g.gui.io.isTouchDevice ? 10.0f : 8.0f) * world.main.g.final_gui_scale();
}

View file

@ -79,6 +79,9 @@ class DrawingProgram {
void invalidate_cache_at_component(CanvasComponentContainer::ObjInfo* objToCheck);
void preupdate_component(CanvasComponentContainer::ObjInfo* objToCheck);
void send_transforms_for(const std::vector<CanvasComponentContainer::ObjInfo*>& objsToSendTransformsFor);
void send_group_ids_for(const std::vector<CanvasComponentContainer::ObjInfo*>& objs);
void force_clients_to_own_view();
void process_selected_images(bool freistellen, bool compress);
void on_tab_out();
void input_add_file_to_canvas_callback(const CustomEvents::AddFileToCanvasEvent& addFile);
@ -105,6 +108,8 @@ class DrawingProgram {
std::unique_ptr<DrawingProgramToolBase> drawTool;
private:
void process_transform_message(const std::vector<std::pair<NetworkingObjects::NetObjID, CoordSpaceHelper>>& transforms);
void process_group_id_message(const std::vector<std::pair<NetworkingObjects::NetObjID, uint64_t>>& groupIds);
void process_force_camera_jump(const CoordSpaceHelper& coords, const Vector2f& windowSize);
void drag_drop_update();
void check_updateable_components();

View file

@ -44,6 +44,9 @@
#include "../GUIStuff/ElementHelpers/RadioButtonHelpers.hpp"
#include "../GUIStuff/ElementHelpers/LayoutHelpers.hpp"
#include "../GUIStuff/ElementHelpers/ButtonHelpers.hpp"
#include <Helpers/Random.hpp>
#include <limits>
#include <unordered_set>
DrawingProgramSelection::DrawingProgramSelection(DrawingProgram& initDrawP):
drawP(initDrawP)
@ -75,6 +78,22 @@ void DrawingProgramSelection::selection_gui(Toolbar& t) {
});
text_label(gui, "Stroke Color");
});
left_to_right_line_layout(gui, [&]() {
text_button(gui, "group selection", "Group", {
.onClick = [&] { group_selection(); }
});
text_button(gui, "ungroup selection", "Ungroup", {
.onClick = [&] { ungroup_selection(); }
});
});
left_to_right_line_layout(gui, [&]() {
text_button(gui, "compress selection images", "Compress Images", {
.onClick = [&] { drawP.process_selected_images(false, true); }
});
text_button(gui, "freistellen selection images", "Freistellen", {
.onClick = [&] { drawP.process_selected_images(true, true); }
});
});
}
});
}
@ -327,14 +346,50 @@ std::function<bool(const std::shared_ptr<DrawingProgramCacheBVHNode>&)> DrawingP
return toRet;
}
void DrawingProgramSelection::expand_selection_with_groups() {
std::unordered_set<uint64_t> groupIds;
for(auto& c : selectedSet) {
if(c->obj->groupId != 0)
groupIds.insert(c->obj->groupId);
}
if(groupIds.empty())
return;
std::unordered_set<CanvasComponentContainer::ObjInfo*> already(selectedSet.begin(), selectedSet.end());
for(auto* c : drawP.layerMan.get_flattened_component_list()) {
if(c->obj->groupId != 0 && groupIds.contains(c->obj->groupId) && !already.contains(c)) {
selectedSet.push_back(c);
already.insert(c);
}
}
}
void DrawingProgramSelection::group_selection() {
if(selectedSet.size() < 2)
return;
uint64_t newGroupId = Random::get().int_range<uint64_t>(1, std::numeric_limits<uint64_t>::max());
for(auto& c : selectedSet)
c->obj->groupId = newGroupId;
drawP.send_group_ids_for(selectedSet);
}
void DrawingProgramSelection::ungroup_selection() {
if(selectedSet.empty())
return;
for(auto& c : selectedSet)
c->obj->groupId = 0;
drawP.send_group_ids_for(selectedSet);
}
void DrawingProgramSelection::add_to_selection(const std::vector<CanvasComponentContainer::ObjInfo*>& newSelection) {
selectedSet.insert(selectedSet.end(), newSelection.begin(), newSelection.end());
expand_selection_with_groups();
sort_selection();
calculate_aabb();
}
void DrawingProgramSelection::set_to_selection(const std::vector<CanvasComponentContainer::ObjInfo*>& newSelection) {
selectedSet = newSelection;
expand_selection_with_groups();
sort_selection();
calculate_aabb();
}

View file

@ -82,6 +82,9 @@ class DrawingProgramSelection {
void set_to_selection(const std::vector<CanvasComponentContainer::ObjInfo*>& newSelection);
void add_to_selection(const std::vector<CanvasComponentContainer::ObjInfo*>& newSelection);
void expand_selection_with_groups();
void group_selection();
void ungroup_selection();
void calculate_aabb();
void reset_all();
void reset_transform_data();

View file

@ -69,6 +69,12 @@ nlohmann::json GlobalConfig::get_config_json(const InputManager& input) const {
toRet["flipZoomToolDirection"] = flipZoomToolDirection;
toRet["realTimeEraser"] = realTimeEraser;
toRet["disableTouchForDrawing"] = disableTouchForDrawing;
toRet["imageCompressOnInsert"] = imageCompressOnInsert;
toRet["imageCompressFormat"] = imageCompressFormat;
toRet["imageWebpQuality"] = imageWebpQuality;
toRet["imageFreistellenOnInsert"] = imageFreistellenOnInsert;
toRet["imageFreistellenMode"] = imageFreistellenMode;
toRet["imageWhiteKeyThreshold"] = imageWhiteKeyThreshold;
#ifndef __EMSCRIPTEN__
toRet["checkForUpdates"] = checkForUpdates;
#endif
@ -147,6 +153,12 @@ void GlobalConfig::set_config_json(InputManager& input, const nlohmann::json& j,
try{j.at("defaultCanvasBackgroundColor").get_to(defaultCanvasBackgroundColor);} catch(...) {}
try{j.at("flipZoomToolDirection").get_to(flipZoomToolDirection);} catch(...) {}
try{j.at("realTimeEraser").get_to(realTimeEraser);} catch(...) {}
try{j.at("imageCompressOnInsert").get_to(imageCompressOnInsert);} catch(...) {}
try{j.at("imageCompressFormat").get_to(imageCompressFormat);} catch(...) {}
try{j.at("imageWebpQuality").get_to(imageWebpQuality);} catch(...) {}
try{j.at("imageFreistellenOnInsert").get_to(imageFreistellenOnInsert);} catch(...) {}
try{j.at("imageFreistellenMode").get_to(imageFreistellenMode);} catch(...) {}
try{j.at("imageWhiteKeyThreshold").get_to(imageWhiteKeyThreshold);} catch(...) {}
#ifndef __EMSCRIPTEN__
try{j.at("checkForUpdates").get_to(checkForUpdates);} catch(...) {}
#endif

View file

@ -21,6 +21,7 @@
#include <nlohmann/json.hpp>
#include <Helpers/VersionNumber.hpp>
#include "InputManager.hpp"
#include "ImagePreprocess.hpp"
#define DEFAULT_CANVAS_BACKGROUND_COLOR Vector3f{0.07f, 0.07f, 0.07f}
@ -112,6 +113,14 @@ class GlobalConfig {
std::string displayName;
bool flipZoomToolDirection = false;
// Fork: image insert options
bool imageCompressOnInsert = true;
ImagePreprocess::CompressFormat imageCompressFormat = ImagePreprocess::CompressFormat::WEBP;
int imageWebpQuality = 80;
bool imageFreistellenOnInsert = false;
ImagePreprocess::FreistellenMode imageFreistellenMode = ImagePreprocess::FreistellenMode::WHITE_KEY;
int imageWhiteKeyThreshold = 245;
bool disableGraphicsDriverWorkarounds = false;
int vsyncValue = 0;
@ -135,3 +144,16 @@ NLOHMANN_JSON_SERIALIZE_ENUM(GlobalConfig::AntiAliasing, {
{GlobalConfig::AntiAliasing::SKIA, "Skia"},
{GlobalConfig::AntiAliasing::DYNAMIC_MSAA, "Dynamic MSAA"},
})
NLOHMANN_JSON_SERIALIZE_ENUM(ImagePreprocess::CompressFormat, {
{ImagePreprocess::CompressFormat::KEEP, "Keep"},
{ImagePreprocess::CompressFormat::WEBP, "WebP"},
{ImagePreprocess::CompressFormat::PNG, "PNG"},
})
NLOHMANN_JSON_SERIALIZE_ENUM(ImagePreprocess::FreistellenMode, {
{ImagePreprocess::FreistellenMode::OFF, "Off"},
{ImagePreprocess::FreistellenMode::WHITE_KEY, "WhiteKey"},
{ImagePreprocess::FreistellenMode::REMBG_SCHNELL, "RembgSchnell"},
{ImagePreprocess::FreistellenMode::REMBG_ALLGEMEIN, "RembgAllgemein"},
})

315
src/ImagePreprocess.cpp Normal file
View file

@ -0,0 +1,315 @@
/*
* InfiniPaint (fork)
* Image insert preprocessing: freistellen + compression options.
*/
#include "ImagePreprocess.hpp"
#include "GlobalConfig.hpp"
#include <Helpers/Logger.hpp>
#include <include/codec/SkBmpDecoder.h>
#include <include/codec/SkCodec.h>
#include <include/codec/SkGifDecoder.h>
#include <include/codec/SkIcoDecoder.h>
#include <include/codec/SkJpegDecoder.h>
#include <include/codec/SkPngDecoder.h>
#include <include/codec/SkWbmpDecoder.h>
#include <include/codec/SkWebpDecoder.h>
#include <include/core/SkBitmap.h>
#include <include/core/SkData.h>
#include <include/core/SkPixmap.h>
#include <include/core/SkStream.h>
#include <include/encode/SkPngEncoder.h>
#include <include/encode/SkWebpEncoder.h>
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <optional>
#include <random>
#include <vector>
#ifndef __EMSCRIPTEN__
#include <cstdlib>
#include <array>
#endif
namespace ImagePreprocess {
namespace {
std::array<const SkCodecs::Decoder, 7> imageDecoders = {
SkBmpDecoder::Decoder(),
SkGifDecoder::Decoder(),
SkIcoDecoder::Decoder(),
SkJpegDecoder::Decoder(),
SkPngDecoder::Decoder(),
SkWbmpDecoder::Decoder(),
SkWebpDecoder::Decoder()
};
std::string to_lower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return s;
}
std::string replace_extension(std::string name, const char* newExt) {
auto slash = name.find_last_of("/\\");
auto baseStart = slash == std::string::npos ? 0 : slash + 1;
auto dot = name.find_last_of('.');
if(dot != std::string::npos && dot > baseStart)
name = name.substr(0, dot);
name += newExt;
return name;
}
bool decode_first_frame(const std::string& data, SkBitmap& outBitmap) {
sk_sp<SkData> skData = SkData::MakeWithoutCopy(data.data(), data.size());
std::unique_ptr<SkCodec> codec = SkCodec::MakeFromData(skData, imageDecoders);
if(!codec)
return false;
SkImageInfo info = codec->getInfo().makeColorType(kRGBA_8888_SkColorType).makeAlphaType(kUnpremul_SkAlphaType);
if(!outBitmap.tryAllocPixels(info))
return false;
SkCodec::Options opts;
opts.fFrameIndex = 0;
if(codec->getPixels(info, outBitmap.getPixels(), outBitmap.rowBytes(), &opts) != SkCodec::Result::kSuccess)
return false;
return true;
}
void apply_white_key(SkBitmap& bitmap, int threshold) {
threshold = std::clamp(threshold, 0, 255);
SkPixmap pm;
if(!bitmap.peekPixels(&pm))
return;
const int w = pm.width();
const int h = pm.height();
for(int y = 0; y < h; y++) {
auto* row = reinterpret_cast<uint8_t*>(pm.writable_addr(0, y));
for(int x = 0; x < w; x++) {
uint8_t* p = row + x * 4;
if(p[0] >= threshold && p[1] >= threshold && p[2] >= threshold) {
p[3] = 0;
}
else if(p[0] > threshold - 25 && p[1] > threshold - 25 && p[2] > threshold - 25) {
// Soft edge: fade alpha near threshold
int minC = std::min({p[0], p[1], p[2]});
int fade = threshold - minC;
int alpha = (fade * 255) / 25;
p[3] = static_cast<uint8_t>(std::clamp(std::min<int>(p[3], alpha), 0, 255));
}
}
}
}
std::optional<std::string> encode_bitmap(const SkBitmap& bitmap, CompressFormat format, int webpQuality) {
SkPixmap pm;
if(!bitmap.peekPixels(&pm))
return std::nullopt;
SkDynamicMemoryWStream out;
bool ok = false;
if(format == CompressFormat::WEBP) {
SkWebpEncoder::Options opts;
opts.fCompression = SkWebpEncoder::Compression::kLossy;
opts.fQuality = static_cast<float>(std::clamp(webpQuality, 0, 100));
ok = SkWebpEncoder::Encode(&out, pm, opts);
}
else {
ok = SkPngEncoder::Encode(&out, pm, {});
}
if(!ok)
return std::nullopt;
out.flush();
auto skData = out.detachAsData();
if(!skData)
return std::nullopt;
return std::string(reinterpret_cast<const char*>(skData->bytes()), skData->size());
}
#ifndef __EMSCRIPTEN__
std::optional<std::string> find_rembg_bin() {
const char* envBin = std::getenv("TOWER_REMBG_BIN");
if(envBin && envBin[0] && std::filesystem::is_regular_file(envBin))
return std::string(envBin);
const char* home = std::getenv("HOME");
if(home) {
std::filesystem::path venv = std::filesystem::path(home) / ".local/share/tower-rembg/bin/rembg";
if(std::filesystem::is_regular_file(venv))
return venv.string();
std::filesystem::path local = std::filesystem::path(home) / ".local/bin/rembg";
if(std::filesystem::is_regular_file(local))
return local.string();
}
// PATH lookup via `command -v`
FILE* pipe = popen("command -v rembg 2>/dev/null", "r");
if(!pipe)
return std::nullopt;
char buf[512];
std::string path;
while(fgets(buf, sizeof(buf), pipe))
path += buf;
pclose(pipe);
while(!path.empty() && (path.back() == '\n' || path.back() == '\r'))
path.pop_back();
if(!path.empty() && std::filesystem::is_regular_file(path))
return path;
return std::nullopt;
}
std::optional<std::string> run_rembg(const std::string& inputData, const std::string& model) {
auto rembg = find_rembg_bin();
if(!rembg) {
Logger::get().log(Logger::LogType::INFO, "[ImagePreprocess] rembg not found — falling back to white-key");
return std::nullopt;
}
std::error_code ec;
auto tmpDir = std::filesystem::temp_directory_path(ec);
if(ec)
return std::nullopt;
auto inPath = tmpDir / ("infinipaint-rembg-in-" + std::to_string(std::random_device{}()) + ".png");
auto outPath = tmpDir / ("infinipaint-rembg-out-" + std::to_string(std::random_device{}()) + ".png");
{
std::ofstream out(inPath, std::ios::binary);
if(!out)
return std::nullopt;
out.write(inputData.data(), static_cast<std::streamsize>(inputData.size()));
}
std::string cmd = "\"" + *rembg + "\" i -m " + model + " \"" + inPath.string() + "\" \"" + outPath.string() + "\" 2>/dev/null";
int rc = std::system(cmd.c_str());
std::optional<std::string> result;
if(rc == 0 && std::filesystem::is_regular_file(outPath)) {
std::ifstream in(outPath, std::ios::binary);
if(in) {
result = std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
if(result->empty())
result.reset();
}
}
std::filesystem::remove(inPath, ec);
std::filesystem::remove(outPath, ec);
return result;
}
#endif
} // namespace
bool looks_like_raster_image(std::string_view name, std::string_view data) {
std::string lower = to_lower(std::string(name));
auto hasExt = [&](const char* ext) {
return lower.size() >= std::strlen(ext) && lower.compare(lower.size() - std::strlen(ext), std::strlen(ext), ext) == 0;
};
if(hasExt(".png") || hasExt(".jpg") || hasExt(".jpeg") || hasExt(".webp") || hasExt(".bmp") || hasExt(".gif") || hasExt(".ico"))
return true;
if(data.size() >= 8) {
// PNG / JPEG / RIFF WEBP / GIF / BMP magic
const auto* b = reinterpret_cast<const unsigned char*>(data.data());
if(b[0] == 0x89 && b[1] == 'P' && b[2] == 'N' && b[3] == 'G')
return true;
if(b[0] == 0xFF && b[1] == 0xD8)
return true;
if(b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F')
return true;
if(b[0] == 'G' && b[1] == 'I' && b[2] == 'F')
return true;
if(b[0] == 'B' && b[1] == 'M')
return true;
}
return false;
}
Options options_from_config(const GlobalConfig& conf) {
Options o;
o.compressOnInsert = conf.imageCompressOnInsert;
o.compressFormat = conf.imageCompressFormat;
o.webpQuality = conf.imageWebpQuality;
o.freistellenOnInsert = conf.imageFreistellenOnInsert;
o.freistellenMode = conf.imageFreistellenMode;
o.whiteKeyThreshold = conf.imageWhiteKeyThreshold;
return o;
}
bool preprocess_resource(std::string& name, std::shared_ptr<std::string>& data, const Options& options) {
if(!data || data->empty())
return false;
if(!looks_like_raster_image(name, *data))
return false;
bool wantFreistellen = options.freistellenOnInsert && options.freistellenMode != FreistellenMode::OFF;
bool wantCompress = options.compressOnInsert && options.compressFormat != CompressFormat::KEEP;
if(!wantFreistellen && !wantCompress)
return false;
std::string working = *data;
bool changed = false;
if(wantFreistellen) {
#ifndef __EMSCRIPTEN__
if(options.freistellenMode == FreistellenMode::REMBG_SCHNELL || options.freistellenMode == FreistellenMode::REMBG_ALLGEMEIN) {
const char* model = options.freistellenMode == FreistellenMode::REMBG_SCHNELL ? "silueta" : "bria-rmbg";
// rembg wants an image file; feed current bytes (encode as PNG first if needed)
SkBitmap bmp;
std::string rembgInput = working;
if(decode_first_frame(working, bmp)) {
auto png = encode_bitmap(bmp, CompressFormat::PNG, 100);
if(png)
rembgInput = *png;
}
if(auto rembgOut = run_rembg(rembgInput, model)) {
working = *rembgOut;
changed = true;
name = replace_extension(name, ".png");
}
else {
// Fallback white-key
if(decode_first_frame(working, bmp)) {
apply_white_key(bmp, options.whiteKeyThreshold);
if(auto enc = encode_bitmap(bmp, CompressFormat::PNG, 100)) {
working = *enc;
changed = true;
name = replace_extension(name, ".png");
}
}
}
}
else
#endif
{
SkBitmap bmp;
if(decode_first_frame(working, bmp)) {
apply_white_key(bmp, options.whiteKeyThreshold);
if(auto enc = encode_bitmap(bmp, CompressFormat::PNG, 100)) {
working = *enc;
changed = true;
name = replace_extension(name, ".png");
}
}
}
}
if(wantCompress) {
SkBitmap bmp;
if(decode_first_frame(working, bmp)) {
CompressFormat fmt = options.compressFormat;
if(auto enc = encode_bitmap(bmp, fmt, options.webpQuality)) {
// Keep result if smaller, or if freistellen required alpha-friendly format
bool preferNew = enc->size() < working.size() || wantFreistellen || fmt == CompressFormat::WEBP;
if(preferNew) {
working = *enc;
changed = true;
name = replace_extension(name, fmt == CompressFormat::WEBP ? ".webp" : ".png");
}
}
}
}
if(changed)
data = std::make_shared<std::string>(std::move(working));
return changed;
}
}

45
src/ImagePreprocess.hpp Normal file
View file

@ -0,0 +1,45 @@
/*
* InfiniPaint (fork)
* Image insert preprocessing: freistellen + compression options.
*/
#pragma once
#include <cstdint>
#include <memory>
#include <string>
#include <string_view>
class GlobalConfig;
namespace ImagePreprocess {
enum class CompressFormat : uint8_t {
KEEP = 0,
WEBP,
PNG
};
enum class FreistellenMode : uint8_t {
OFF = 0,
WHITE_KEY, // Near-white → transparent (always available)
REMBG_SCHNELL, // rembg -m silueta (Tower freistellen/schnell)
REMBG_ALLGEMEIN // rembg -m bria-rmbg (Tower freistellen/allgemein)
};
struct Options {
bool compressOnInsert = true;
CompressFormat compressFormat = CompressFormat::WEBP;
int webpQuality = 80; // 0–100, lossy
bool freistellenOnInsert = false;
FreistellenMode freistellenMode = FreistellenMode::WHITE_KEY;
int whiteKeyThreshold = 245; // 0–255, RGB channels all >= threshold → transparent
};
// Mutates name + data in place when preprocessing applies. Returns true if changed.
bool preprocess_resource(std::string& name, std::shared_ptr<std::string>& data, const Options& options);
Options options_from_config(const GlobalConfig& conf);
bool looks_like_raster_image(std::string_view name, std::string_view data);
}

View file

@ -346,6 +346,14 @@ void PhoneDrawingProgramScreen::top_toolbar_settings_popup() {
.floating = {.offset = {.x = 0, .y = static_cast<float>(io.theme->padding1)}, .zIndex = gui.get_z_index(), .attachPoints = {.element = CLAY_ATTACH_POINT_RIGHT_TOP, .parent = CLAY_ATTACH_POINT_RIGHT_BOTTOM}, .attachTo = CLAY_ATTACH_TO_PARENT}
}) {
text_label_centered(gui, "Player List");
if(main.world->netServer) {
text_button(gui, "force all jump to host phone", "Bring Everyone Here", {
.wide = true,
.onClick = [&] {
main.world->drawProg.force_clients_to_own_view();
}
});
}
left_to_right_line_layout(gui, [&]() {
CLAY_AUTO_ID({
.layout = {

View file

@ -1211,6 +1211,14 @@ void Toolbar::player_list() {
gui.new_id("client list", [&] {
text_label_centered(gui, "Player List");
if(!main.world->clientStillConnecting) {
if(main.world->netServer) {
text_button(gui, "force all jump to host", "Bring Everyone Here", {
.wide = true,
.onClick = [&] {
main.world->drawProg.force_clients_to_own_view();
}
});
}
left_to_right_line_layout(gui, [&]() {
CLAY_AUTO_ID({
.layout = {
@ -1463,6 +1471,7 @@ void Toolbar::general_settings_inner_gui() {
});
};
category_button("Generalbutton", "General", GSETTINGS_GENERAL);
category_button("Imagesbutton", "Images", GSETTINGS_IMAGES);
category_button("Graphicsbutton", "Graphics", GSETTINGS_GRAPHICS);
category_button("Tabletbutton", "Tablet", GSETTINGS_TABLET);
category_button("Themebutton", "Theme", GSETTINGS_THEME);
@ -1524,6 +1533,35 @@ void Toolbar::general_settings_inner_gui() {
});
break;
}
case GSETTINGS_IMAGES: {
general_scroll_area("image settings", [&] {
text_label(gui, "On insert / paste:");
checkbox_boolean_field(gui, "compress images on insert", "Compress images automatically", &main.conf.imageCompressOnInsert);
text_label(gui, "Compression format:");
radio_button_selector(gui, "image compress format", &main.conf.imageCompressFormat, {
{"Keep original", ImagePreprocess::CompressFormat::KEEP},
{"WebP", ImagePreprocess::CompressFormat::WEBP},
{"PNG", ImagePreprocess::CompressFormat::PNG}
});
slider_scalar_field(gui, "webp quality", "WebP quality", &main.conf.imageWebpQuality, 1, 100);
checkbox_boolean_field(gui, "freistellen on insert", "Freistellen / remove background on insert", &main.conf.imageFreistellenOnInsert);
text_label(gui, "Freistellen mode:");
radio_button_selector(gui, "freistellen mode", &main.conf.imageFreistellenMode, {
{"Off", ImagePreprocess::FreistellenMode::OFF},
{"White background", ImagePreprocess::FreistellenMode::WHITE_KEY},
#ifndef __EMSCRIPTEN__
{"Schnell (rembg silueta)", ImagePreprocess::FreistellenMode::REMBG_SCHNELL},
{"Allgemein (rembg bria)", ImagePreprocess::FreistellenMode::REMBG_ALLGEMEIN},
#endif
});
slider_scalar_field(gui, "white key threshold", "White-key threshold", &main.conf.imageWhiteKeyThreshold, 200, 255);
text_label_light(gui, "Selection toolbar: Compress Images / Freistellen also work afterwards.");
#ifndef __EMSCRIPTEN__
text_label_light(gui, "rembg uses Tower venv (~/.local/share/tower-rembg) when present.");
#endif
});
break;
}
case GSETTINGS_GRAPHICS: {
general_scroll_area("graphics settings", [&] {
input_scalar_field(gui, "Max GUI Scale", "Max GUI Scale", &main.conf.guiScale, 0.5f, 5.0f, {

View file

@ -169,6 +169,7 @@ class Toolbar {
} optionsMenuType;
enum GeneralSettingsOptions {
GSETTINGS_GENERAL = 0,
GSETTINGS_IMAGES,
GSETTINGS_GRAPHICS,
GSETTINGS_TABLET,
GSETTINGS_THEME,

View file

@ -30,6 +30,7 @@ VersionNumber header_to_version_number(const std::string& header) {
m["INFPNT000004"] = VersionNumber(0, 3, 0);
m["INFPNT000005"] = VersionNumber(0, 4, 0);
m["INFPNT000006"] = VersionNumber(0, 6, 0);
m["INFPNT000007"] = VersionNumber(0, 7, 0);
}
auto it = m.find(header);
if(it == m.end())

View file

@ -28,7 +28,7 @@ namespace VersionConstants {
VersionNumber header_to_version_number(const std::string& header);
constexpr int SAVEFILE_HEADER_LEN = 12; // DO NOT CHANGE THIS HEADER LENGTH
const std::string CURRENT_SAVEFILE_HEADER = "INFPNT000006"; // Change whenever the save file is incompatible with the previous version
const std::string CURRENT_VERSION_STRING = "0.6.1";
constexpr VersionNumber CURRENT_VERSION_NUMBER(0, 6, 1);
const std::string CURRENT_SAVEFILE_HEADER = "INFPNT000007"; // Change whenever the save file is incompatible with the previous version
const std::string CURRENT_VERSION_STRING = "0.7.0-fork";
constexpr VersionNumber CURRENT_VERSION_NUMBER(0, 7, 0);
}