fix(license): remove cascading commercial-use bit encoding, clarify Allow Selling label (#941)

- _resolve_commercial_bits() no longer has Sell-implies-Image
  cascading; each CommercialUse value sets only its own bit,
  matching CivitAI's modern array-format API.
- Keep filter tag label as 'Allow Selling' for brevity; add
  title/tooltip 'Allow selling generated images' on hover.
- Same tooltip treatment for 'No Credit Required'.
- Add i18n keys for both tooltips across all 10 locales.
This commit is contained in:
Will Miao
2026-05-26 06:02:17 +08:00
parent 2629fcce23
commit d7caa1fa47
19 changed files with 122 additions and 57 deletions

View File

@@ -232,6 +232,8 @@
"license": "Lizenz",
"noCreditRequired": "Kein Credit erforderlich",
"allowSellingGeneratedContent": "Verkauf erlaubt",
"allowSellingGeneratedContentTooltip": "Verkauf generierter Bilder erlauben",
"noCreditRequiredTooltip": "Modell ohne Nennung des Erstellers verwenden",
"noTags": "Keine Tags",
"autoTags": "Auto-Tags",
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",

View File

@@ -232,6 +232,8 @@
"license": "License",
"noCreditRequired": "No Credit Required",
"allowSellingGeneratedContent": "Allow Selling",
"allowSellingGeneratedContentTooltip": "Allow selling generated images",
"noCreditRequiredTooltip": "Use the model without crediting the creator",
"noTags": "No tags",
"autoTags": "Auto Tags",
"noBaseModelMatches": "No base models match the current search.",

View File

@@ -232,6 +232,8 @@
"license": "Licencia",
"noCreditRequired": "Sin crédito requerido",
"allowSellingGeneratedContent": "Venta permitida",
"allowSellingGeneratedContentTooltip": "Permitir la venta de imágenes generadas",
"noCreditRequiredTooltip": "Usar el modelo sin atribuir al creador",
"noTags": "Sin etiquetas",
"autoTags": "Etiquetas automáticas",
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",

View File

@@ -232,6 +232,8 @@
"license": "Licence",
"noCreditRequired": "Crédit non requis",
"allowSellingGeneratedContent": "Vente autorisée",
"allowSellingGeneratedContentTooltip": "Autoriser la vente d\"images générées",
"noCreditRequiredTooltip": "Utiliser le modèle sans créditer le créateur",
"noTags": "Aucun tag",
"autoTags": "Auto-Tags",
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",

View File

@@ -232,6 +232,8 @@
"license": "רישיון",
"noCreditRequired": "ללא קרדיט נדרש",
"allowSellingGeneratedContent": "אפשר מכירה",
"allowSellingGeneratedContentTooltip": "אפשר מכירת תמונות שנוצרו",
"noCreditRequiredTooltip": "שימוש במודל ללא מתן קרדיט ליוצר",
"noTags": "ללא תגיות",
"autoTags": "תגיות אוטומטיות",
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",

View File

@@ -232,6 +232,8 @@
"license": "ライセンス",
"noCreditRequired": "クレジット不要",
"allowSellingGeneratedContent": "販売許可",
"allowSellingGeneratedContentTooltip": "生成した画像の販売を許可",
"noCreditRequiredTooltip": "クレジット表記なしでモデルを使用可能",
"noTags": "タグなし",
"autoTags": "自動タグ",
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",

View File

@@ -232,6 +232,8 @@
"license": "라이선스",
"noCreditRequired": "크레딧 표기 없음",
"allowSellingGeneratedContent": "판매 허용",
"allowSellingGeneratedContentTooltip": "생성된 이미지 판매 허용",
"noCreditRequiredTooltip": "크리에이터 저작자 표시 없이 모델 사용 가능",
"noTags": "태그 없음",
"autoTags": "자동 태그",
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",

View File

@@ -232,6 +232,8 @@
"license": "Лицензия",
"noCreditRequired": "Без указания авторства",
"allowSellingGeneratedContent": "Продажа разрешена",
"allowSellingGeneratedContentTooltip": "Разрешить продажу сгенерированных изображений",
"noCreditRequiredTooltip": "Использование модели без указания автора",
"noTags": "Без тегов",
"autoTags": "Авто-теги",
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",

View File

@@ -232,6 +232,8 @@
"license": "许可证",
"noCreditRequired": "无需署名",
"allowSellingGeneratedContent": "允许销售",
"allowSellingGeneratedContentTooltip": "允许出售生成的图片",
"noCreditRequiredTooltip": "使用模型时无需注明原作者",
"noTags": "无标签",
"autoTags": "自动标签",
"noBaseModelMatches": "没有基础模型符合当前搜索。",

View File

@@ -232,6 +232,8 @@
"license": "授權",
"noCreditRequired": "無需署名",
"allowSellingGeneratedContent": "允許銷售",
"allowSellingGeneratedContentTooltip": "允許出售生成的圖片",
"noCreditRequiredTooltip": "使用模型時無需註明原作者",
"noTags": "無標籤",
"autoTags": "自動標籤",
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",

View File

@@ -239,9 +239,9 @@ def _resolve_commercial_bits(values: Sequence[str]) -> int:
normalized_values.add(normalized)
has_sell = "sell" in normalized_values
has_rent = has_sell or "rent" in normalized_values
has_rentcivit = has_rent or "rentcivit" in normalized_values
has_image = has_sell or "image" in normalized_values
has_rent = "rent" in normalized_values
has_rentcivit = "rentcivit" in normalized_values
has_image = "image" in normalized_values
commercial_bits = (
(1 if has_sell else 0) << 3

View File

@@ -218,10 +218,10 @@
<div class="filter-section">
<h4>{{ t('header.filter.license') }}</h4>
<div class="filter-tags">
<div class="filter-tag license-tag" data-license="noCredit">
<div class="filter-tag license-tag" data-license="noCredit" title="{{ t('header.filter.noCreditRequiredTooltip') }}">
{{ t('header.filter.noCreditRequired') }}
</div>
<div class="filter-tag license-tag" data-license="allowSelling">
<div class="filter-tag license-tag" data-license="allowSelling" title="{{ t('header.filter.allowSellingGeneratedContentTooltip') }}">
{{ t('header.filter.allowSellingGeneratedContent') }}
</div>
</div>

View File

@@ -65,32 +65,26 @@ async def test_allow_selling_filter():
"""Test the allow selling generated content filtering logic."""
service = DummyModelService()
# Create test data with different license flags
# CommercialUse values are independent — Sell does NOT imply Image.
test_data = [
# Model allowing selling (contains Image in allowCommercialUse)
{"file_path": "model1.safetensors", "license_flags": build_license_flags({"allowCommercialUse": ["Image"]})},
# Model not allowing selling (doesn't contain Image in allowCommercialUse)
{"file_path": "model2.safetensors", "license_flags": build_license_flags({"allowCommercialUse": ["RentCivit"]})},
# Model with default license flags (includes Sell by default, which implies Image)
{"file_path": "model3.safetensors", "license_flags": build_license_flags(None)},
# Model allowing selling (contains Sell in allowCommercialUse, which implies Image)
{"file_path": "model4.safetensors", "license_flags": build_license_flags({"allowCommercialUse": ["Sell"]})},
# Model with empty allowCommercialUse (doesn't allow selling)
{"file_path": "model5.safetensors", "license_flags": build_license_flags({"allowCommercialUse": []})},
]
# Test allow_selling=True (should return models that allow selling - have Image permission)
# Default and Sell permissions both include Image, so model3 and model4 will be included
# Test allow_selling=True (should return only models with the Image permission)
filtered = await service._apply_allow_selling_filter(test_data, allow_selling=True)
assert len(filtered) == 3 # model1, model3 (default includes Sell which implies Image), model4
assert len(filtered) == 1 # only model1 has Image permission
file_paths = {item["file_path"] for item in filtered}
assert file_paths == {"model1.safetensors", "model3.safetensors", "model4.safetensors"}
assert file_paths == {"model1.safetensors"}
# Test allow_selling=False (should return models that don't allow selling - don't have Image permission)
# Test allow_selling=False (should return models without the Image permission)
filtered = await service._apply_allow_selling_filter(test_data, allow_selling=False)
assert len(filtered) == 2 # model2 and model5
assert len(filtered) == 4 # model2, model3, model4, model5
file_paths = {item["file_path"] for item in filtered}
assert file_paths == {"model2.safetensors", "model5.safetensors"}
assert file_paths == {"model2.safetensors", "model3.safetensors", "model4.safetensors", "model5.safetensors"}
@pytest.mark.asyncio

View File

@@ -131,13 +131,12 @@ async def test_pool_filter_allow_selling_true(lora_service, sample_loras):
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
# Should keep models with Image permission (allowSelling)
# Models: no_credit_required_for_selling, credit_required_for_selling, default_license
assert len(filtered) == 3
# Sell alone does not imply Image, so default_license is excluded.
assert len(filtered) == 2
file_names = {lora["file_name"] for lora in filtered}
assert file_names == {
"no_credit_required_for_selling.safetensors",
"credit_required_for_selling.safetensors",
"default_license.safetensors",
}
@@ -178,12 +177,11 @@ async def test_pool_filter_both_license_filters(lora_service, sample_loras):
# Should keep models where both conditions are met:
# - allowNoCredit=True (no credit required)
# - Image permission exists (allow selling)
# Models: no_credit_required_for_selling, default_license
assert len(filtered) == 2
# default_license has ["Sell"] without Image, so it's excluded.
assert len(filtered) == 1
file_names = {lora["file_name"] for lora in filtered}
assert file_names == {
"no_credit_required_for_selling.safetensors",
"default_license.safetensors",
}

View File

@@ -132,7 +132,8 @@ async def test_initialize_cache_populates_cache(tmp_path: Path):
_normalize_path(tmp_path / "one.txt"),
_normalize_path(tmp_path / "nested" / "two.txt"),
}
assert {item["license_flags"] for item in cache.raw_data} == {DEFAULT_LICENSE_FLAGS}
# build_license_flags({}) returns 113 (defaults: allowNoCredit + ["Sell"] + derivatives + differentLicense)
assert {item["license_flags"] for item in cache.raw_data} == {113}
assert scanner._hash_index.get_path("hash-one") == _normalize_path(tmp_path / "one.txt")
assert scanner._hash_index.get_path("hash-two") == _normalize_path(tmp_path / "nested" / "two.txt")
@@ -190,7 +191,8 @@ async def test_initialize_in_background_applies_scan_result(tmp_path: Path, monk
_normalize_path(tmp_path / "one.txt"),
_normalize_path(tmp_path / "nested" / "two.txt"),
}
assert {item["license_flags"] for item in cache.raw_data} == {DEFAULT_LICENSE_FLAGS}
# build_license_flags({}) returns 113 (defaults: allowNoCredit + ["Sell"] + derivatives + differentLicense)
assert {item["license_flags"] for item in cache.raw_data} == {113}
assert scanner._hash_index.get_path("hash-two") == _normalize_path(tmp_path / "nested" / "two.txt")
assert scanner._tags_count == {"alpha": 1, "beta": 1}
assert scanner._excluded_models == [_normalize_path(tmp_path / "skip-file.txt")]

View File

@@ -16,7 +16,9 @@ def test_resolve_license_payload_defaults():
assert payload["allowDerivatives"] is True
assert payload["allowDifferentLicense"] is True
assert payload["allowCommercialUse"] == ["Sell"]
assert flags == 127
# Default ["Sell"] only sets the Sell bit (16), plus NoCredit (1),
# Derivatives (32) and DifferentLicense (64) = 113.
assert flags == 113
def test_build_license_flags_custom_values():
@@ -34,11 +36,10 @@ def test_build_license_flags_custom_values():
assert payload["allowDifferentLicense"] is False
flags = build_license_flags(source)
# Sell automatically enables all commercial bits including image.
assert flags == 30
assert flags == 18
def test_build_license_flags_respects_commercial_hierarchy():
def test_build_license_flags_independent_values():
base = {
"allowNoCredit": False,
"allowDerivatives": False,
@@ -46,14 +47,10 @@ def test_build_license_flags_respects_commercial_hierarchy():
}
assert build_license_flags({**base, "allowCommercialUse": []}) == 0
# Rent adds rent and rentcivit permissions.
assert build_license_flags({**base, "allowCommercialUse": ["Rent"]}) == 12
# RentCivit alone should only set its own bit.
assert build_license_flags({**base, "allowCommercialUse": ["Rent"]}) == 8
assert build_license_flags({**base, "allowCommercialUse": ["RentCivit"]}) == 4
# Image only toggles the image bit.
assert build_license_flags({**base, "allowCommercialUse": ["Image"]}) == 2
# Sell forces all commercial bits regardless of image listing.
assert build_license_flags({**base, "allowCommercialUse": ["Sell"]}) == 30
assert build_license_flags({**base, "allowCommercialUse": ["Sell"]}) == 16
def test_build_license_flags_parses_aggregate_string():

View File

@@ -5,7 +5,7 @@
</div>
<div class="section__toggles">
<label class="toggle-item">
<span class="toggle-item__label">No Credit Required</span>
<span class="toggle-item__label" title="Use the model without crediting the creator">No Credit Required</span>
<button
type="button"
class="toggle-switch"
@@ -20,7 +20,7 @@
</label>
<label class="toggle-item">
<span class="toggle-item__label">Allow Selling</span>
<span class="toggle-item__label" title="Allow selling generated images">Allow Selling</span>
<button
type="button"
class="toggle-switch"

View File

@@ -398,13 +398,13 @@
align-items: center;
}
.section[data-v-dea4adf6] {
.section[data-v-07ddd3df] {
margin-bottom: 16px;
}
.section__header[data-v-dea4adf6] {
.section__header[data-v-07ddd3df] {
margin-bottom: 8px;
}
.section__title[data-v-dea4adf6] {
.section__title[data-v-07ddd3df] {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
@@ -412,21 +412,21 @@
color: var(--fg-color, #fff);
opacity: 0.6;
}
.section__toggles[data-v-dea4adf6] {
.section__toggles[data-v-07ddd3df] {
display: flex;
gap: 16px;
}
.toggle-item[data-v-dea4adf6] {
.toggle-item[data-v-07ddd3df] {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.toggle-item__label[data-v-dea4adf6] {
.toggle-item__label[data-v-07ddd3df] {
font-size: 12px;
color: var(--fg-color, #fff);
}
.toggle-switch[data-v-dea4adf6] {
.toggle-switch[data-v-07ddd3df] {
position: relative;
width: 36px;
height: 20px;
@@ -435,7 +435,7 @@
border: none;
cursor: pointer;
}
.toggle-switch__track[data-v-dea4adf6] {
.toggle-switch__track[data-v-07ddd3df] {
position: absolute;
inset: 0;
background: var(--comfy-input-bg, #333);
@@ -443,11 +443,11 @@
border-radius: 10px;
transition: all 0.2s;
}
.toggle-switch--active .toggle-switch__track[data-v-dea4adf6] {
.toggle-switch--active .toggle-switch__track[data-v-07ddd3df] {
background: rgba(66, 153, 225, 0.3);
border-color: rgba(66, 153, 225, 0.6);
}
.toggle-switch__thumb[data-v-dea4adf6] {
.toggle-switch__thumb[data-v-07ddd3df] {
position: absolute;
top: 3px;
left: 2px;
@@ -458,12 +458,12 @@
transition: all 0.2s;
opacity: 0.6;
}
.toggle-switch--active .toggle-switch__thumb[data-v-dea4adf6] {
.toggle-switch--active .toggle-switch__thumb[data-v-07ddd3df] {
transform: translateX(16px);
background: #4299e1;
opacity: 1;
}
.toggle-switch:hover .toggle-switch__thumb[data-v-dea4adf6] {
.toggle-switch:hover .toggle-switch__thumb[data-v-07ddd3df] {
opacity: 1;
}
@@ -2223,7 +2223,7 @@ to { transform: rotate(360deg);
})();
var _a;
import { app as app$1 } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { api as api$1 } from "../../../scripts/api.js";
/**
* @vue/shared v3.5.26
* (c) 2018-present Yuxi (Evan) You and Vue contributors
@@ -11094,7 +11094,10 @@ const _sfc_main$i = /* @__PURE__ */ defineComponent({
], -1)),
createBaseVNode("div", _hoisted_2$e, [
createBaseVNode("label", _hoisted_3$c, [
_cache[3] || (_cache[3] = createBaseVNode("span", { class: "toggle-item__label" }, "No Credit Required", -1)),
_cache[3] || (_cache[3] = createBaseVNode("span", {
class: "toggle-item__label",
title: "Use the model without crediting the creator"
}, "No Credit Required", -1)),
createBaseVNode("button", {
type: "button",
class: normalizeClass(["toggle-switch", { "toggle-switch--active": __props.noCreditRequired }]),
@@ -11107,7 +11110,10 @@ const _sfc_main$i = /* @__PURE__ */ defineComponent({
])], 10, _hoisted_4$a)
]),
createBaseVNode("label", _hoisted_5$8, [
_cache[5] || (_cache[5] = createBaseVNode("span", { class: "toggle-item__label" }, "Allow Selling", -1)),
_cache[5] || (_cache[5] = createBaseVNode("span", {
class: "toggle-item__label",
title: "Allow selling generated images"
}, "Allow Selling", -1)),
createBaseVNode("button", {
type: "button",
class: normalizeClass(["toggle-switch", { "toggle-switch--active": __props.allowSelling }]),
@@ -11124,7 +11130,7 @@ const _sfc_main$i = /* @__PURE__ */ defineComponent({
};
}
});
const LicenseSection = /* @__PURE__ */ _export_sfc(_sfc_main$i, [["__scopeId", "data-v-dea4adf6"]]);
const LicenseSection = /* @__PURE__ */ _export_sfc(_sfc_main$i, [["__scopeId", "data-v-07ddd3df"]]);
const _hoisted_1$e = { class: "preview" };
const _hoisted_2$d = { class: "preview__title" };
const _hoisted_3$b = ["disabled"];
@@ -15031,6 +15037,54 @@ function createModeChangeCallback(node, updateDownstreamLoaders2, nodeSpecificCa
};
}
const app = {};
const api = {
fetchApi: (...args) => fetch(...args),
addEventListener: (eventName, handler) => document.addEventListener(eventName, handler),
removeEventListener: (eventName, handler) => document.removeEventListener(eventName, handler)
};
let _loraSyntaxFormatCache = null;
let _loraSyntaxFormatRefreshPromise = null;
async function _fetchLoraSyntaxFormat() {
try {
const response = await api.fetchApi("/lm/settings");
if (response.ok) {
const data = await response.json();
if (data.success && data.settings) {
_loraSyntaxFormatCache = data.settings.lora_syntax_format || "legacy";
return;
}
}
} catch (e) {
}
if (_loraSyntaxFormatCache === null) {
_loraSyntaxFormatCache = "legacy";
}
}
function _triggerBackgroundRefresh() {
if (_loraSyntaxFormatRefreshPromise) {
return;
}
_loraSyntaxFormatRefreshPromise = _fetchLoraSyntaxFormat().finally(() => {
_loraSyntaxFormatRefreshPromise = null;
});
}
function _initLoraSyntaxFormat() {
_triggerBackgroundRefresh();
}
_initLoraSyntaxFormat();
function _initLoraSyntaxFormatReactive() {
window.addEventListener("storage", (e) => {
if (e.key === "lm:lora-syntax-format-changed") {
_triggerBackgroundRefresh();
}
});
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
_triggerBackgroundRefresh();
}
});
}
_initLoraSyntaxFormatReactive();
const ROOT_GRAPH_ID = "root";
const LORA_PROVIDER_NODE_TYPES = [
"Lora Stacker (LoraManager)",
@@ -15407,7 +15461,7 @@ function createLoraRandomizerWidget(node) {
const vueApp = createApp(LoraRandomizerWidget, {
widget,
node,
api
api: api$1
});
vueApp.use(PrimeVue, {
unstyled: true,
@@ -15482,7 +15536,7 @@ function createLoraCyclerWidget(node) {
const vueApp = createApp(LoraCyclerWidget, {
widget,
node,
api
api: api$1
});
vueApp.use(PrimeVue, {
unstyled: true,

File diff suppressed because one or more lines are too long